From d7b9f2e67feafedf35494ed81c8bfe5d09258f96 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:01:52 +0100 Subject: [PATCH] Backend test hardening + quality enforcement (#872) - Reworked many tests - Enforcing lint + type checking for test suite - Fixed various issues surfaced by the new tests - CI tweaks --- .github/workflows/ci.yml | 62 +- Makefile | 30 +- pyproject.toml | 34 +- readme.md | 1 + shelfmark/config/settings.py | 3 +- shelfmark/core/self_user_routes.py | 7 +- shelfmark/core/settings_registry.py | 63 +- shelfmark/download/orchestrator.py | 4 +- shelfmark/download/postprocess/policy.py | 24 +- shelfmark/main.py | 17 +- tests/README.md | 3 + tests/audiobookbay/test_handler.py | 185 +- tests/audiobookbay/test_scraper.py | 222 +- tests/audiobookbay/test_source.py | 194 +- tests/audiobookbay/test_utils.py | 32 +- tests/bypass/test_external_bypasser.py | 103 + tests/bypass/test_internal_bypasser.py | 4 +- tests/config/test_dns_settings_live_apply.py | 4 +- tests/config/test_docker_volumes.py | 727 +----- .../config/test_download_legacy_migration.py | 108 + tests/config/test_download_settings.py | 36 +- tests/config/test_entrypoint_permissions.py | 9 +- tests/config/test_environment.py | 121 +- .../config/test_mirror_settings_live_apply.py | 8 +- tests/config/test_oidc_settings.py | 9 +- tests/config/test_search_mode_settings.py | 7 +- tests/config/test_security.py | 66 +- tests/config/test_users_settings.py | 44 +- tests/core/test_activity_routes_api.py | 241 +- .../core/test_activity_terminal_snapshots.py | 7 +- .../core/test_activity_view_state_service.py | 17 +- tests/core/test_admin_users_api.py | 126 +- tests/core/test_auth_api.py | 173 +- tests/core/test_booklore_multiuser.py | 1 + tests/core/test_builtin_multiuser.py | 24 +- tests/core/test_config_access_guardrails.py | 495 ++++ tests/core/test_config_api.py | 110 +- tests/core/test_config_user_overrides.py | 104 +- .../test_destination_file_organization.py | 131 + tests/core/test_download_api_guardrails.py | 48 +- tests/core/test_download_processing.py | 404 +-- tests/core/test_hardlink.py | 242 +- tests/core/test_library_processing.py | 2321 ----------------- tests/core/test_manual_query.py | 6 +- tests/core/test_naming.py | 255 +- tests/core/test_notifications_settings_api.py | 4 +- tests/core/test_oidc_auth.py | 32 +- tests/core/test_oidc_integration.py | 24 +- tests/core/test_oidc_routes.py | 100 +- tests/core/test_part_number_extraction.py | 82 +- tests/core/test_per_user_downloads.py | 22 +- tests/core/test_processing_integration.py | 356 ++- .../core/test_releases_api_direct_provider.py | 36 +- tests/core/test_request_policy.py | 186 +- tests/core/test_request_routes_api.py | 1444 +++++++--- tests/core/test_requests_service.py | 104 +- tests/core/test_search_plan.py | 2 +- ..._self_user_notification_preferences_api.py | 5 +- tests/core/test_self_user_routes.py | 198 +- tests/direct_download/test_handler.py | 116 +- tests/direct_download/test_search_queries.py | 135 +- tests/download/test_http_aa_redirects.py | 16 +- .../download/test_http_bypasser_fallbacks.py | 20 + tests/download/test_http_download_url.py | 158 ++ tests/download/test_network_dns_failover.py | 122 + .../download/test_network_proxy_selection.py | 61 + tests/download/test_orchestrator_lifecycle.py | 12 +- .../test_postprocess_scan_blocking_io.py | 1 - tests/download/test_ssl_verify.py | 100 +- tests/e2e/conftest.py | 136 +- tests/e2e/test_api.py | 265 +- tests/e2e/test_auth_endpoints.py | 325 ++- tests/e2e/test_auth_flow.py | 262 +- tests/e2e/test_conftest_helpers.py | 205 +- tests/e2e/test_download_flow.py | 384 ++- tests/e2e/test_prowlarr_flow.py | 312 ++- tests/e2e/test_proxy_auth_middleware.py | 403 +-- tests/irc/test_cache.py | 12 +- tests/irc/test_source.py | 108 + .../metadata/test_hardcover_field_options.py | 110 +- tests/metadata/test_hardcover_lists.py | 8 +- tests/metadata/test_hardcover_search_title.py | 2 - .../metadata/test_hardcover_series_search.py | 129 +- tests/prowlarr/test_bencode.py | 22 +- tests/prowlarr/test_cache.py | 5 +- tests/prowlarr/test_clients.py | 15 +- tests/prowlarr/test_deluge_client.py | 28 +- tests/prowlarr/test_failure_scenarios.py | 341 ++- tests/prowlarr/test_handler.py | 509 ++-- tests/prowlarr/test_integration_clients.py | 262 +- tests/prowlarr/test_integration_failures.py | 67 +- tests/prowlarr/test_integration_handler.py | 86 +- tests/prowlarr/test_nzbget_client.py | 159 +- tests/prowlarr/test_qbittorrent_client.py | 190 +- tests/prowlarr/test_remote_path_mappings.py | 208 +- tests/prowlarr/test_rtorrent_client.py | 21 +- tests/prowlarr/test_sabnzbd_client.py | 162 +- tests/prowlarr/test_source.py | 8 +- tests/prowlarr/test_torrent_utils.py | 36 +- tests/prowlarr/test_transmission_client.py | 6 +- 100 files changed, 8607 insertions(+), 6347 deletions(-) create mode 100644 tests/bypass/test_external_bypasser.py create mode 100644 tests/config/test_download_legacy_migration.py create mode 100644 tests/core/test_config_access_guardrails.py create mode 100644 tests/core/test_destination_file_organization.py delete mode 100644 tests/core/test_library_processing.py create mode 100644 tests/download/test_http_download_url.py create mode 100644 tests/download/test_network_dns_failover.py create mode 100644 tests/download/test_network_proxy_selection.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d556c..a201813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,8 @@ permissions: contents: read jobs: - backend-tests: + backend-quality: + name: Backend Quality runs-on: ubuntu-latest steps: - name: Checkout @@ -22,22 +23,65 @@ jobs: enable-cache: true - name: Sync dependencies - run: uv sync --locked --extra browser + run: make install-python-dev - name: Lint backend - run: uv run ruff check shelfmark + run: make python-lint - name: Check backend formatting - run: uv run ruff format --check shelfmark - - - name: Typecheck backend - run: uv run basedpyright + run: make python-format-check - name: Check backend dead code - run: uv run vulture shelfmark + run: make python-dead-code + + - name: Lint tests + run: make python-test-lint + + - name: Check test formatting + run: make python-test-format-check + + backend-typechecks: + name: Backend Typechecks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv and Python + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version: "0.11.3" + python-version: "3.14" + enable-cache: true + + - name: Sync dependencies + run: make install-python-dev + + - name: Typecheck backend + run: make python-typecheck + + - name: Typecheck tests + run: make python-test-typecheck + + backend-tests: + name: Backend Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv and Python + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version: "0.11.3" + python-version: "3.14" + enable-cache: true + + - name: Sync dependencies + run: make install-python-dev - name: Run tests - run: uv run pytest tests/ -x --tb=short + run: uv run pytest tests/ -x --tb=short -m "not integration and not e2e" docker-build-check: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 8e85b5c..dd2fabd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks +.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks # Frontend directory FRONTEND_DIR := src/frontend @@ -26,6 +26,12 @@ help: @echo " python-typecheck - Run BasedPyright against Python backend code" @echo " python-dead-code - Run Vulture against Python backend code" @echo " python-checks - Run all Python static analysis checks" + @echo " python-test-lint - Run Ruff against Python tests with the relaxed tests profile" + @echo " python-test-lint-fix - Run Ruff with safe auto-fixes against Python tests" + @echo " python-test-format - Format Python tests with Ruff" + @echo " python-test-format-check - Check Python test formatting with Ruff" + @echo " python-test-typecheck - Run lightweight BasedPyright checks against Python tests" + @echo " python-test-checks - Run all relaxed Python test static analysis checks" @echo " clean - Remove node_modules and build artifacts" @echo "" @echo "Backend (Docker):" @@ -99,6 +105,28 @@ python-dead-code: python-checks: python-lint python-format-check python-typecheck python-dead-code +python-test-lint: + @echo "Running Ruff against tests with the relaxed tests profile..." + uv run ruff check tests + +python-test-lint-fix: + @echo "Running Ruff with safe auto-fixes against tests..." + uv run ruff check tests --fix + +python-test-format: + @echo "Formatting Python tests with Ruff..." + uv run ruff format tests + +python-test-format-check: + @echo "Checking Python test formatting with Ruff..." + uv run ruff format --check tests + +python-test-typecheck: + @echo "Running lightweight BasedPyright checks against tests..." + uv run basedpyright tests --skipunannotated + +python-test-checks: python-test-lint python-test-format-check python-test-typecheck + # Run frontend unit tests frontend-test: @echo "Running frontend unit tests..." diff --git a/pyproject.toml b/pyproject.toml index 05dad4b..5f97700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,10 +95,36 @@ ignore = ["D", "EM", "FBT", "PLR2004", "UP035", "TRY003", "E501", "TD002", "S104 [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "ANN", - "S101", - "S105", - "S108", - "S311", + "BLE001", + "B010", + "B017", + "B028", + "DTZ", + "E402", + "E731", + "ERA001", + "FURB", + "G003", + "G004", + "PERF", + "PIE", + "PLC0414", + "PLW0108", + "PLW1510", + "PLW2901", + "PTH", + "PT028", + "PYI034", + "Q000", + "RET", + "RUF012", + "S", + "SIM", + "TC001", + "TC002", + "TC003", + "TRY", + "UP028", ] [tool.basedpyright] diff --git a/readme.md b/readme.md index bc834d8..861a9ab 100644 --- a/readme.md +++ b/readme.md @@ -233,6 +233,7 @@ Log level is configurable via Settings or `LOG_LEVEL` environment variable. # Python tooling make install-python-dev # Sync Python runtime + dev tools with uv make python-checks # Run Ruff, BasedPyright, and Vulture +make python-test-checks # Run lightweight lint/type checks for tests # Frontend development make install # Install dependencies diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 2bf897c..7c82cc4 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -544,7 +544,8 @@ def search_mode_settings() -> list[SettingsField]: @register_settings("network", "Network", icon="globe", order=10) def network_settings() -> list[SettingsField]: """Network and connectivity settings.""" - # Check if Tor is currently enabled. + # Avoid querying the live config singleton while settings are still being + # registered, which can recurse back into this module during import. tor_enabled = env.USING_TOR # When Tor is enabled, DNS/proxy settings are overridden by iptables rules diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index fe3595a..481feda 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -23,7 +23,6 @@ from shelfmark.core.auth_modes import ( ) from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger -from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_settings_overrides import ( build_user_preferences_payload as _build_user_preferences_payload, ) @@ -154,8 +153,10 @@ def _normalize_visible_self_settings_sections(raw_sections: object) -> list[str] def _get_visible_self_settings_sections() -> list[str]: - users_config = load_config_file("users") - raw_sections = users_config.get(_VISIBLE_SELF_SETTINGS_SECTIONS_KEY) + raw_sections = app_config.get( + _VISIBLE_SELF_SETTINGS_SECTIONS_KEY, + list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS), + ) return _normalize_visible_self_settings_sections(raw_sections) diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 214c84e..b1011c3 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -668,13 +668,16 @@ def migrate_mirror_settings() -> None: def migrate_legacy_settings() -> None: """Migrate legacy settings to new unified file destination format. - Maps old settings to new: - - PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION - - INGEST_DIR / LIBRARY_PATH -> DESTINATION - - LIBRARY_TEMPLATE -> TEMPLATE + Maps stable legacy settings to the current download model: + - INGEST_DIR -> DESTINATION + - USE_BOOK_TITLE -> FILE_ORGANIZATION - USE_CONTENT_TYPE_DIRECTORIES -> AA_CONTENT_TYPE_ROUTING - INGEST_DIR_* -> AA_CONTENT_TYPE_DIR_* - TORRENT_HARDLINK -> HARDLINK_TORRENTS / HARDLINK_TORRENTS_AUDIOBOOK + + Intentionally ignores the short-lived pre-1.0 library-mode settings + (`PROCESSING_MODE`, `LIBRARY_PATH`, `LIBRARY_TEMPLATE`, etc.), which were + replaced before the first stable release shipped. """ # Load existing downloads config downloads_config = load_config_file("downloads") @@ -685,17 +688,19 @@ def migrate_legacy_settings() -> None: # Skip migration if no legacy settings exist (fresh install) legacy_keys = { - "PROCESSING_MODE", "INGEST_DIR", - "LIBRARY_PATH", "USE_BOOK_TITLE", - "LIBRARY_TEMPLATE", - "PROCESSING_MODE_AUDIOBOOK", "INGEST_DIR_AUDIOBOOK", - "LIBRARY_PATH_AUDIOBOOK", - "LIBRARY_TEMPLATE_AUDIOBOOK", "TORRENT_HARDLINK", "USE_CONTENT_TYPE_DIRECTORIES", + "INGEST_DIR_BOOK_FICTION", + "INGEST_DIR_BOOK_NON_FICTION", + "INGEST_DIR_BOOK_UNKNOWN", + "INGEST_DIR_MAGAZINE", + "INGEST_DIR_COMIC_BOOK", + "INGEST_DIR_STANDARDS_DOCUMENT", + "INGEST_DIR_MUSICAL_SCORE", + "INGEST_DIR_OTHER", } if not any(key in downloads_config for key in legacy_keys): return @@ -703,48 +708,18 @@ def migrate_legacy_settings() -> None: migrated_downloads = {} migrated_sources = {} - # === BOOKS MIGRATION === - old_mode = downloads_config.get("PROCESSING_MODE", "ingest") old_ingest_dir = downloads_config.get("INGEST_DIR", "/cwa-book-ingest") - old_library_path = downloads_config.get("LIBRARY_PATH", "") old_use_book_title = downloads_config.get("USE_BOOK_TITLE", True) - old_library_template = downloads_config.get("LIBRARY_TEMPLATE", "{Author}/{Title}") - # Map PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION - if old_mode == "library": - migrated_downloads["FILE_ORGANIZATION"] = "organize" - migrated_downloads["DESTINATION"] = old_library_path or "/books" - migrated_downloads["TEMPLATE"] = old_library_template + migrated_downloads["DESTINATION"] = old_ingest_dir + if old_use_book_title: + migrated_downloads["FILE_ORGANIZATION"] = "rename" else: - if old_use_book_title: - migrated_downloads["FILE_ORGANIZATION"] = "rename" - migrated_downloads["TEMPLATE"] = "{Author} - {Title} ({Year})" - else: - migrated_downloads["FILE_ORGANIZATION"] = "none" - migrated_downloads["DESTINATION"] = old_ingest_dir - - # === AUDIOBOOKS MIGRATION === - old_mode_ab = downloads_config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - old_ingest_dir_ab = downloads_config.get("INGEST_DIR_AUDIOBOOK", "") - old_library_path_ab = downloads_config.get("LIBRARY_PATH_AUDIOBOOK", "") - old_library_template_ab = downloads_config.get("LIBRARY_TEMPLATE_AUDIOBOOK", "{Author}/{Title}") - - if old_mode_ab == "library": - migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "organize" - migrated_downloads["DESTINATION_AUDIOBOOK"] = old_library_path_ab or "" - migrated_downloads["TEMPLATE_AUDIOBOOK"] = old_library_template_ab - else: - migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "rename" - migrated_downloads["TEMPLATE_AUDIOBOOK"] = "{Author} - {Title}" - if old_ingest_dir_ab: - migrated_downloads["DESTINATION_AUDIOBOOK"] = old_ingest_dir_ab + migrated_downloads["FILE_ORGANIZATION"] = "none" # === HARDLINK MIGRATION === old_torrent_hardlink = downloads_config.get("TORRENT_HARDLINK") if old_torrent_hardlink is not None: - # Books default to False (ingest folder use case) - # Audiobooks default to True (library folder use case) - # But if explicitly set, apply to both migrated_downloads["HARDLINK_TORRENTS"] = old_torrent_hardlink migrated_downloads["HARDLINK_TORRENTS_AUDIOBOOK"] = old_torrent_hardlink diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index fd20069..0631102 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -1,6 +1,6 @@ """Download queue orchestration and worker management. -Two-stage architecture: handlers stage to TMP_DIR, orchestrator moves to INGEST_DIR +Two-stage architecture: handlers stage to TMP_DIR, orchestrator delivers to the configured destination with archive extraction and custom script support. """ @@ -538,7 +538,7 @@ def _task_to_dict( "priority": task.priority, "added_time": task.added_time, "progress": task.progress, - "status": task.status, + "status": retry_status.value if isinstance(retry_status, QueueStatus) else task.status, "status_message": task.status_message, "download_path": task.download_path, "user_id": task.user_id, diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py index a34e1da..40f8e5c 100644 --- a/shelfmark/download/postprocess/policy.py +++ b/shelfmark/download/postprocess/policy.py @@ -17,7 +17,6 @@ circular imports (`archive` is used by the pipeline). from __future__ import annotations import shelfmark.core.config as core_config -from shelfmark.core.request_helpers import coerce_bool def _normalize_format_list(value: object, default: list[str]) -> list[str]: @@ -53,23 +52,11 @@ def get_file_organization(*, is_audiobook: bool) -> str: """Get the file organization mode for the content type.""" key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION" mode = _config_text(core_config.config.get(key, "rename")).strip().lower() - - # Handle legacy settings migration - if mode not in ("none", "rename", "organize"): - legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE" - legacy_mode = _config_text(core_config.config.get(legacy_key, "ingest")).strip().lower() - if legacy_mode == "library": - return "organize" - if coerce_bool(core_config.config.get("USE_BOOK_TITLE", True), default=True): - return "rename" - return "none" - - return mode + return mode if mode in ("none", "rename", "organize") else "rename" def get_template(*, is_audiobook: bool, organization_mode: str) -> str: """Get the template for the content type and organization mode.""" - # Determine the correct key based on content type and organization mode if is_audiobook: if organization_mode == "organize": key = "TEMPLATE_AUDIOBOOK_ORGANIZE" @@ -80,15 +67,6 @@ def get_template(*, is_audiobook: bool, organization_mode: str) -> str: template = _config_text(core_config.config.get(key, "")) - # Fallback to legacy keys if new keys are empty - if not template: - legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE" - template = _config_text(core_config.config.get(legacy_key, "")) - - if not template: - legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE" - template = _config_text(core_config.config.get(legacy_key, "")) - if not template: if organization_mode == "organize": return "{Author}/{Title} ({Year})" diff --git a/shelfmark/main.py b/shelfmark/main.py index fa1545b..198bc1e 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -26,7 +26,6 @@ from shelfmark.config.env import ( BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, - DEBUG, FLASK_HOST, FLASK_PORT, HIDE_LOCAL_AUTH, @@ -98,6 +97,13 @@ _OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Err _IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS) +def _is_debug_enabled() -> bool: + debug_value = app_config.get("DEBUG", False) + if isinstance(debug_value, str): + return string_to_bool(debug_value) + return bool(debug_value) + + def _raise_runtime_error(message: str) -> NoReturn: raise RuntimeError(message) @@ -535,7 +541,7 @@ if user_db is not None: # Enable CORS in development mode for local frontend development -if DEBUG: +if _is_debug_enabled(): CORS( app, resources={ @@ -890,7 +896,7 @@ def favicon(_: Any = None) -> Response: return send_from_directory(FRONTEND_DIST, "favicon.ico", mimetype="image/vnd.microsoft.icon") -if DEBUG: +if _is_debug_enabled(): import subprocess def _stop_gui() -> None: @@ -3299,16 +3305,17 @@ if not _is_config_dir_writable(): ) if __name__ == "__main__": + debug_enabled = _is_debug_enabled() logger.info( "Starting Flask application with WebSocket support on %s:%s (debug=%s)", FLASK_HOST, FLASK_PORT, - DEBUG, + debug_enabled, ) socketio.run( app, host=FLASK_HOST, port=FLASK_PORT, - debug=DEBUG, + debug=debug_enabled, allow_unsafe_werkzeug=True, # For development only ) diff --git a/tests/README.md b/tests/README.md index b045420..d3558c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,6 +14,9 @@ uv run pytest tests/ -v -m "not integration and not e2e" # Run Python static analysis make python-checks +# Run lightweight test lint/type checks +make python-test-checks + # Run E2E API tests against a running app stack uv run pytest tests/e2e/ -v -m e2e diff --git a/tests/audiobookbay/test_handler.py b/tests/audiobookbay/test_handler.py index 9cafdfd..27af8b8 100644 --- a/tests/audiobookbay/test_handler.py +++ b/tests/audiobookbay/test_handler.py @@ -4,15 +4,14 @@ Tests for AudiobookBay download handler. from pathlib import Path from threading import Event -from unittest.mock import patch, MagicMock -import pytest +from unittest.mock import MagicMock, patch from shelfmark.core.models import DownloadTask -from shelfmark.release_sources.audiobookbay.handler import AudiobookBayHandler from shelfmark.download.clients import ( - DownloadStatus, DownloadState, + DownloadStatus, ) +from shelfmark.release_sources.audiobookbay.handler import AudiobookBayHandler class ProgressRecorder: @@ -44,18 +43,18 @@ class ProgressRecorder: class TestAudiobookBayHandlerDownload: """Tests for AudiobookBayHandler.download().""" - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_success(self, mock_get_client, mock_extract_magnet): """Test successful download initiation.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = None mock_client.add_download.return_value = "download_id_123" mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -65,7 +64,9 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None) as mock_poll: + with patch.object( + AudiobookBayHandler, "_poll_and_complete", return_value=None + ) as mock_poll: result = handler.download( task=task, cancel_flag=cancel_flag, @@ -75,16 +76,17 @@ class TestAudiobookBayHandlerDownload: assert result is None mock_extract_magnet.assert_called_once_with( - "https://audiobookbay.lu/abss/test-book/", - "audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", "audiobookbay.lu" ) mock_client.add_download.assert_called_once() mock_poll.assert_called_once() assert "resolving" in recorder.statuses - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') - def test_download_uses_source_url_for_hashed_task_id(self, mock_get_client, mock_extract_magnet): + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + def test_download_uses_source_url_for_hashed_task_id( + self, mock_get_client, mock_extract_magnet + ): """Test release queue flow where task_id is source hash and source_url has detail URL.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" @@ -114,17 +116,16 @@ class TestAudiobookBayHandlerDownload: assert result is None mock_extract_magnet.assert_called_once_with( - "https://audiobookbay.lu/abss/test-book/", - "audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", "audiobookbay.lu" ) assert "resolving" in recorder.statuses - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_existing_complete(self, mock_get_client, mock_extract_magnet): """Test handling existing complete download.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = ( @@ -139,7 +140,7 @@ class TestAudiobookBayHandlerDownload: ) mock_client.get_download_path.return_value = "/path/to/book.m4b" mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -149,7 +150,7 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - + with patch.object( AudiobookBayHandler, "_wait_for_completed_path", @@ -161,16 +162,16 @@ class TestAudiobookBayHandlerDownload: progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result == "/path/to/book.m4b" mock_client.add_download.assert_not_called() - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_existing_in_progress(self, mock_get_client, mock_extract_magnet): """Test handling existing in-progress download.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = ( @@ -184,7 +185,7 @@ class TestAudiobookBayHandlerDownload: ), ) mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -194,8 +195,10 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - - with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None) as mock_poll: + + with patch.object( + AudiobookBayHandler, "_poll_and_complete", return_value=None + ) as mock_poll: result = handler.download( task=task, cancel_flag=cancel_flag, @@ -208,8 +211,8 @@ class TestAudiobookBayHandlerDownload: mock_client.add_download.assert_not_called() mock_poll.assert_called_once() - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_cancellation(self, mock_get_client, mock_extract_magnet): """Test that cancellation is respected.""" handler = AudiobookBayHandler() @@ -222,24 +225,24 @@ class TestAudiobookBayHandlerDownload: cancel_flag = Event() cancel_flag.set() # Set immediately recorder = ProgressRecorder() - + result = handler.download( task=task, cancel_flag=cancel_flag, progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result is None assert "cancelled" in recorder.statuses mock_extract_magnet.assert_not_called() - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_no_magnet_link(self, mock_get_client, mock_extract_magnet): """Test handling when magnet link extraction fails.""" mock_extract_magnet.return_value = None - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -261,20 +264,22 @@ class TestAudiobookBayHandlerDownload: progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result is None assert recorder.last_status == "error" assert "magnet link" in recorder.last_message.lower() - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') - @patch('shelfmark.release_sources.audiobookbay.handler.list_configured_clients') - def test_download_no_client_configured(self, mock_list_clients, mock_get_client, mock_extract_magnet): + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + @patch("shelfmark.release_sources.audiobookbay.handler.list_configured_clients") + def test_download_no_client_configured( + self, mock_list_clients, mock_get_client, mock_extract_magnet + ): """Test handling when no torrent client is configured.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" mock_get_client.return_value = None mock_list_clients.return_value = [] - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -284,30 +289,30 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - + result = handler.download( task=task, cancel_flag=cancel_flag, progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result is None assert recorder.last_status == "error" assert "client" in recorder.last_message.lower() - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_client_add_failure(self, mock_get_client, mock_extract_magnet): """Test handling when client.add_download fails.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = None mock_client.add_download.side_effect = Exception("Client error") mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -317,23 +322,23 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - + result = handler.download( task=task, cancel_flag=cancel_flag, progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result is None assert recorder.last_status == "error" - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_existing_no_path(self, mock_get_client, mock_extract_magnet): """Test handling when existing download has no path.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = ( @@ -348,7 +353,7 @@ class TestAudiobookBayHandlerDownload: ) mock_client.get_download_path.return_value = None mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -358,14 +363,14 @@ class TestAudiobookBayHandlerDownload: ) cancel_flag = Event() recorder = ProgressRecorder() - + result = handler.download( task=task, cancel_flag=cancel_flag, progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + assert result is None assert recorder.last_status == "error" assert "path" in recorder.last_message.lower() @@ -374,26 +379,28 @@ class TestAudiobookBayHandlerDownload: class TestAudiobookBayHandlerCategory: """Tests for category selection.""" - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') - @patch('shelfmark.release_sources.audiobookbay.handler.config.get') - def test_category_selection_qbittorrent_audiobook(self, mock_config_get, mock_get_client, mock_extract_magnet): + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + @patch("shelfmark.release_sources.audiobookbay.handler.config.get") + def test_category_selection_qbittorrent_audiobook( + self, mock_config_get, mock_get_client, mock_extract_magnet + ): """Test audiobook category selection for qBittorrent.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + def config_get(key, default=""): if key == "QBITTORRENT_CATEGORY_AUDIOBOOK": return "audiobooks" return default - + mock_config_get.side_effect = config_get - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = None mock_client.add_download.return_value = "download_id" mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -403,7 +410,7 @@ class TestAudiobookBayHandlerCategory: ) cancel_flag = Event() recorder = ProgressRecorder() - + with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None): handler.download( task=task, @@ -411,31 +418,33 @@ class TestAudiobookBayHandlerCategory: progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + # Verify category was passed call_kwargs = mock_client.add_download.call_args.kwargs - assert call_kwargs['category'] == "audiobooks" + assert call_kwargs["category"] == "audiobooks" - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') - @patch('shelfmark.release_sources.audiobookbay.handler.config.get') - def test_category_selection_transmission_general(self, mock_config_get, mock_get_client, mock_extract_magnet): + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + @patch("shelfmark.release_sources.audiobookbay.handler.config.get") + def test_category_selection_transmission_general( + self, mock_config_get, mock_get_client, mock_extract_magnet + ): """Test Transmission audiobook category does not fall back to general category.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + def config_get(key, default=""): if key == "TRANSMISSION_CATEGORY": return "books" return default - + mock_config_get.side_effect = config_get - + mock_client = MagicMock() mock_client.name = "transmission" mock_client.find_existing.return_value = None mock_client.add_download.return_value = "download_id" mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -445,7 +454,7 @@ class TestAudiobookBayHandlerCategory: ) cancel_flag = Event() recorder = ProgressRecorder() - + with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None): handler.download( task=task, @@ -453,26 +462,28 @@ class TestAudiobookBayHandlerCategory: progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + # Transmission audiobook downloads use only the audiobook category key. call_kwargs = mock_client.add_download.call_args.kwargs - assert call_kwargs['category'] is None + assert call_kwargs["category"] is None - @patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link') - @patch('shelfmark.release_sources.audiobookbay.handler.get_client') - @patch('shelfmark.release_sources.audiobookbay.handler.config.get') - def test_category_selection_non_audiobook(self, mock_config_get, mock_get_client, mock_extract_magnet): + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + @patch("shelfmark.release_sources.audiobookbay.handler.config.get") + def test_category_selection_non_audiobook( + self, mock_config_get, mock_get_client, mock_extract_magnet + ): """Test that non-audiobook content types don't get category.""" mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123" - + mock_config_get.return_value = "" - + mock_client = MagicMock() mock_client.name = "qbittorrent" mock_client.find_existing.return_value = None mock_client.add_download.return_value = "download_id" mock_get_client.return_value = mock_client - + handler = AudiobookBayHandler() task = DownloadTask( task_id="https://audiobookbay.lu/abss/test-book/", @@ -482,7 +493,7 @@ class TestAudiobookBayHandlerCategory: ) cancel_flag = Event() recorder = ProgressRecorder() - + with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None): handler.download( task=task, @@ -490,10 +501,10 @@ class TestAudiobookBayHandlerCategory: progress_callback=recorder.progress_callback, status_callback=recorder.status_callback, ) - + # Verify no category was passed call_kwargs = mock_client.add_download.call_args.kwargs - assert call_kwargs['category'] is None + assert call_kwargs["category"] is None class TestAudiobookBayHandlerCancel: diff --git a/tests/audiobookbay/test_scraper.py b/tests/audiobookbay/test_scraper.py index beb7b37..c3370e8 100644 --- a/tests/audiobookbay/test_scraper.py +++ b/tests/audiobookbay/test_scraper.py @@ -2,12 +2,10 @@ Tests for AudiobookBay scraper functions. """ -from unittest.mock import Mock, patch -import pytest +from unittest.mock import patch from shelfmark.release_sources.audiobookbay import scraper - # Mock HTML based on real ABB structure SAMPLE_SEARCH_HTML = """ @@ -96,8 +94,8 @@ DETAIL_HTML_NO_TRACKERS = """ class TestSearchAudiobookbay: """Tests for the search_audiobookbay function.""" - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_success(self, mock_config_get, mock_html_get): """Test successful search with results.""" mock_config_get.return_value = 1.0 # rate_limit_delay @@ -105,43 +103,48 @@ class TestSearchAudiobookbay: SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/page/1/?s=test+query&cat=undefined%2Cundefined", ) - - results = scraper.search_audiobookbay("test query", max_pages=1, hostname="audiobookbay.lu") - - assert len(results) == 2 - assert results[0]['title'] == "Test Book Title - Test Author" - assert results[0]['link'] == "https://audiobookbay.lu/abss/test-book-title-by-author/" - assert results[0]['language'] == "English" - assert results[0]['format'] == "M4B" - assert results[0]['bitrate'] == "128 Kbps" - assert results[0]['size'] == "500.00 MB" - assert results[0]['posted_date'] == "01 Jan 2024" - assert results[0]['cover'] == "https://example.com/cover.jpg" - - assert results[1]['title'] == "Another Test Book - Another Author" - assert results[1]['language'] == "Spanish" - assert results[1]['format'] == "MP3" - assert results[1]['size'] == "1.01 GB" - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + results = scraper.search_audiobookbay("test query", max_pages=1, hostname="audiobookbay.lu") + + assert len(results) == 2 + assert results[0]["title"] == "Test Book Title - Test Author" + assert results[0]["link"] == "https://audiobookbay.lu/abss/test-book-title-by-author/" + assert results[0]["language"] == "English" + assert results[0]["format"] == "M4B" + assert results[0]["bitrate"] == "128 Kbps" + assert results[0]["size"] == "500.00 MB" + assert results[0]["posted_date"] == "01 Jan 2024" + assert results[0]["cover"] == "https://example.com/cover.jpg" + + assert results[1]["title"] == "Another Test Book - Another Author" + assert results[1]["language"] == "Spanish" + assert results[1]["format"] == "MP3" + assert results[1]["size"] == "1.01 GB" + + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_pagination(self, mock_config_get, mock_html_get): """Test pagination through multiple pages.""" mock_config_get.return_value = 0.0 # No delay for faster tests mock_html_get.side_effect = [ (EMPTY_SEARCH_HTML, "https://audiobookbay.lu/"), # Session bootstrap - (SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"), + ( + SAMPLE_SEARCH_HTML, + "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined", + ), (EMPTY_SEARCH_HTML, "https://audiobookbay.lu/page/2/?s=test&cat=undefined%2Cundefined"), ] - + results = scraper.search_audiobookbay("test", max_pages=2, hostname="audiobookbay.lu") - + assert len(results) == 2 # Only from first page assert mock_html_get.call_count == 3 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') - def test_search_audiobookbay_page_one_uses_root_search_endpoint(self, mock_config_get, mock_html_get): + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") + def test_search_audiobookbay_page_one_uses_root_search_endpoint( + self, mock_config_get, mock_html_get + ): """Test page 1 search uses ABB root endpoint instead of /page/1/.""" mock_config_get.return_value = 0.0 mock_html_get.return_value = ( @@ -157,9 +160,11 @@ class TestSearchAudiobookbay: assert "/page/1/" not in requested_url assert "cat=undefined%2Cundefined" in requested_url - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') - def test_search_audiobookbay_bootstraps_and_reuses_session(self, mock_config_get, mock_html_get): + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") + def test_search_audiobookbay_bootstraps_and_reuses_session( + self, mock_config_get, mock_html_get + ): """Test ABB search initializes and reuses a request session for cookie continuity.""" mock_config_get.return_value = 0.0 mock_html_get.side_effect = [ @@ -178,8 +183,8 @@ class TestSearchAudiobookbay: assert bootstrap_call.kwargs["session"] is not None assert search_call.kwargs["session"] is bootstrap_call.kwargs["session"] - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_empty(self, mock_config_get, mock_html_get): """Test search with no results.""" mock_config_get.return_value = 1.0 @@ -187,24 +192,27 @@ class TestSearchAudiobookbay: EMPTY_SEARCH_HTML, "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined", ) - + results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") - + assert len(results) == 0 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_error_non_200(self, mock_config_get, mock_html_get): """Test error handling for non-200 status code.""" mock_config_get.return_value = 1.0 - mock_html_get.return_value = ("", "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined") - + mock_html_get.return_value = ( + "", + "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined", + ) + results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") - + assert len(results) == 0 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_redirect_to_homepage(self, mock_config_get, mock_html_get): """Test handling redirect to homepage (blocked/invalid search).""" mock_config_get.return_value = 1.0 @@ -212,28 +220,31 @@ class TestSearchAudiobookbay: EMPTY_SEARCH_HTML, "https://audiobookbay.lu", # Redirected to homepage ) - + results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") - + assert len(results) == 0 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_request_exception(self, mock_config_get, mock_html_get): """Test handling request exceptions.""" mock_config_get.return_value = 1.0 - mock_html_get.return_value = ("", "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined") - + mock_html_get.return_value = ( + "", + "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined", + ) + results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") - + assert len(results) == 0 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_relative_link(self, mock_config_get, mock_html_get): """Test handling relative links in results.""" mock_config_get.return_value = 1.0 - + html_with_relative_link = """

Test Book

@@ -243,19 +254,19 @@ class TestSearchAudiobookbay:
""" - + mock_html_get.return_value = ( html_with_relative_link, "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined", ) - - results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") - - assert len(results) == 1 - assert results[0]['link'] == "https://audiobookbay.lu/abss/relative-link/" - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") + + assert len(results) == 1 + assert results[0]["link"] == "https://audiobookbay.lu/abss/relative-link/" + + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_protocol_relative_links(self, mock_config_get, mock_html_get): """Test protocol-relative links are normalized without duplicating hostname.""" mock_config_get.return_value = 0.0 @@ -284,8 +295,8 @@ class TestSearchAudiobookbay: assert results[0]["link"] == "https://audiobookbay.lu/abss/protocol-relative/" assert results[0]["cover"] == "https://audiobookbay.lu/wp-content/uploads/cover.jpg" - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") def test_search_audiobookbay_exact_phrase_query(self, mock_config_get, mock_html_get): """Test exact phrase wrapping and encoding in search URL.""" mock_config_get.return_value = 0.0 @@ -306,42 +317,47 @@ class TestSearchAudiobookbay: assert "s=%22test+query%22" in requested_url assert "cat=undefined%2Cundefined" in requested_url - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') - @patch('shelfmark.release_sources.audiobookbay.scraper.config.get') - def test_search_audiobookbay_always_uses_legacy_category_query(self, mock_config_get, mock_html_get): + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") + @patch("shelfmark.release_sources.audiobookbay.scraper.config.get") + def test_search_audiobookbay_always_uses_legacy_category_query( + self, mock_config_get, mock_html_get + ): """Test ABB search always includes legacy category query and does not fallback.""" mock_config_get.return_value = 0.0 - mock_html_get.return_value = ("", "https://audiobookbay.lu/?s=test&cat=undefined%2Cundefined") + mock_html_get.return_value = ( + "", + "https://audiobookbay.lu/?s=test&cat=undefined%2Cundefined", + ) results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu") assert len(results) == 0 assert mock_html_get.call_count >= 2 search_urls = [ - call.args[0] - for call in mock_html_get.call_args_list - if "?s=test" in call.args[0] + call.args[0] for call in mock_html_get.call_args_list if "?s=test" in call.args[0] ] assert search_urls - assert all(url == "https://audiobookbay.lu/?s=test&cat=undefined%2Cundefined" for url in search_urls) + assert all( + url == "https://audiobookbay.lu/?s=test&cat=undefined%2Cundefined" + for url in search_urls + ) class TestExtractMagnetLink: """Tests for the extract_magnet_link function.""" - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_success(self, mock_html_get): """Test successful magnet link extraction.""" mock_html_get.side_effect = [ ("", "https://audiobookbay.lu/"), # Bootstrap attempt SAMPLE_DETAIL_HTML, ] - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is not None assert magnet_link.startswith("magnet:?xt=urn:btih:") assert "ABC123DEF456GHI789JKL012MNO345PQR678STU" in magnet_link @@ -349,7 +365,7 @@ class TestExtractMagnetLink: assert "http%3A//tracker.example.com%3A8080" in magnet_link assert mock_html_get.call_count == 2 - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_reuses_bootstrap_session(self, mock_html_get): """Test detail page fetch reuses the bootstrap session for ABB cookies.""" mock_html_get.side_effect = [ @@ -358,8 +374,7 @@ class TestExtractMagnetLink: ] scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) assert mock_html_get.call_count == 2 @@ -369,59 +384,55 @@ class TestExtractMagnetLink: assert detail_call.args[0] == "https://audiobookbay.lu/abss/test-book/" assert detail_call.kwargs["session"] is bootstrap_call.kwargs["session"] - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_fallback(self, mock_html_get): """Test fallback to default trackers when none found.""" mock_html_get.return_value = DETAIL_HTML_NO_TRACKERS - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is not None assert magnet_link.startswith("magnet:?xt=urn:btih:") assert "ABC123DEF456GHI789JKL012MNO345PQR678STU" in magnet_link # Should contain default trackers assert "udp%3A//tracker.openbittorrent.com%3A80" in magnet_link - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_no_info_hash(self, mock_html_get): """Test handling missing info hash.""" mock_html_get.return_value = "" - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is None - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_non_200(self, mock_html_get): """Test handling non-200 status code.""" mock_html_get.return_value = "" - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is None - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_request_exception(self, mock_html_get): """Test handling request exceptions.""" mock_html_get.return_value = "" - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is None - @patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page') + @patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page") def test_extract_magnet_link_cleans_info_hash(self, mock_html_get): """Test that info hash whitespace is cleaned.""" html_with_whitespace = """ @@ -436,14 +447,13 @@ class TestExtractMagnetLink: """ - + mock_html_get.return_value = html_with_whitespace - + magnet_link = scraper.extract_magnet_link( - "https://audiobookbay.lu/abss/test-book/", - hostname="audiobookbay.lu" + "https://audiobookbay.lu/abss/test-book/", hostname="audiobookbay.lu" ) - + assert magnet_link is not None # Info hash should be cleaned (no spaces, uppercase) assert "ABC123DEF456" in magnet_link diff --git a/tests/audiobookbay/test_source.py b/tests/audiobookbay/test_source.py index 7bca177..3b227f6 100644 --- a/tests/audiobookbay/test_source.py +++ b/tests/audiobookbay/test_source.py @@ -2,17 +2,18 @@ Tests for AudiobookBay release source. """ -from unittest.mock import Mock, patch +from unittest.mock import patch + import pytest -from shelfmark.metadata_providers import BookMetadata from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant +from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources.audiobookbay.source import ( AudiobookBaySource, - _map_language, _generate_source_id, - _split_title_and_author, + _map_language, _parse_bitrate_to_kbps, + _split_title_and_author, ) @@ -110,29 +111,33 @@ class TestAudiobookBaySource: def test_is_available_enabled(self, monkeypatch): """Test is_available when enabled.""" + def mock_get(key, default=False): if key == "ABB_ENABLED": return True if key == "ABB_HOSTNAME": return "audiobookbay.lu" return default - + import shelfmark.release_sources.audiobookbay.source as source_module + monkeypatch.setattr(source_module.config, "get", mock_get) - + source = AudiobookBaySource() assert source.is_available() is True def test_is_available_disabled(self, monkeypatch): """Test is_available when disabled.""" + def mock_get(key, default=False): if key == "ABB_ENABLED": return False return default - + import shelfmark.release_sources.audiobookbay.source as source_module + monkeypatch.setattr(source_module.config, "get", mock_get) - + source = AudiobookBaySource() assert source.is_available() is False @@ -152,15 +157,15 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="ebook") assert results == [] - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_query_generation_manual(self, mock_search): """Test search with manual query.""" mock_search.return_value = [] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -176,18 +181,18 @@ class TestAudiobookBaySource: grouped_title_variants=[], manual_query="custom search query", ) - + source.search(book, plan, content_type="audiobook") - + mock_search.assert_called_once() call_args = mock_search.call_args - assert call_args.kwargs['query'] == "custom search query" + assert call_args.kwargs["query"] == "custom search query" - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_query_generation_from_variants(self, mock_search): """Test search query generation from title variants with title-only retry.""" mock_search.return_value = [] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -202,20 +207,20 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - + source.search(book, plan, content_type="audiobook") - + assert mock_search.call_count == 2 first_call = mock_search.call_args_list[0] second_call = mock_search.call_args_list[1] - assert first_call.kwargs['query'] == "test book test author" - assert second_call.kwargs['query'] == "test book" + assert first_call.kwargs["query"] == "test book test author" + assert second_call.kwargs["query"] == "test book" - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_query_generation_from_title_only(self, mock_search): """Test search query generation when only title available.""" mock_search.return_value = [] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -230,14 +235,14 @@ class TestAudiobookBaySource: title_variants=[], grouped_title_variants=[], ) - + source.search(book, plan, content_type="audiobook") - + mock_search.assert_called_once() call_args = mock_search.call_args - assert call_args.kwargs['query'] == "test book" + assert call_args.kwargs["query"] == "test book" - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_empty_query(self, mock_search): """Test search with empty query returns empty.""" source = AudiobookBaySource() @@ -254,32 +259,32 @@ class TestAudiobookBaySource: title_variants=[], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="audiobook") - + assert results == [] mock_search.assert_not_called() - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_relevance_filtering(self, mock_search): """Test that irrelevant results are filtered out.""" mock_search.return_value = [ { - 'title': 'Test Book by Test Author', - 'link': 'https://audiobookbay.lu/abss/test-book/', - 'format': 'M4B', - 'size': '500 MB', - 'language': 'English', + "title": "Test Book by Test Author", + "link": "https://audiobookbay.lu/abss/test-book/", + "format": "M4B", + "size": "500 MB", + "language": "English", }, { - 'title': 'Something Completely Different', - 'link': 'https://audiobookbay.lu/abss/unrelated/', - 'format': 'MP3', - 'size': '1 GB', - 'language': 'English', + "title": "Something Completely Different", + "link": "https://audiobookbay.lu/abss/unrelated/", + "format": "MP3", + "size": "1 GB", + "language": "English", }, ] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -294,29 +299,29 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="audiobook") - + # Should filter out "Unrelated Book Title" as it doesn't contain query words assert len(results) == 1 - assert results[0].title == 'Test Book by Test Author' + assert results[0].title == "Test Book by Test Author" - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_result_mapping(self, mock_search): """Test conversion of scraper results to Release objects.""" mock_search.return_value = [ { - 'title': 'Test Book - Test Author', - 'link': 'https://audiobookbay.lu/abss/test-book/', - 'format': 'M4B', - 'size': '500.00 MBs', - 'language': 'English', - 'bitrate': '128 Kbps', - 'posted_date': '01 Jan 2024', - 'cover': 'https://example.com/cover.jpg', + "title": "Test Book - Test Author", + "link": "https://audiobookbay.lu/abss/test-book/", + "format": "M4B", + "size": "500.00 MBs", + "language": "English", + "bitrate": "128 Kbps", + "posted_date": "01 Jan 2024", + "cover": "https://example.com/cover.jpg", }, ] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -331,9 +336,9 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="audiobook") - + assert len(results) == 1 release = results[0] assert release.source == "audiobookbay" @@ -346,33 +351,33 @@ class TestAudiobookBaySource: assert release.protocol.value == "torrent" assert release.indexer == "AudiobookBay" assert release.content_type == "audiobook" - assert release.extra['preview'] == "https://example.com/cover.jpg" - assert release.extra['detail_url'] == "https://audiobookbay.lu/abss/test-book/" - assert release.extra['bitrate'] == "128 Kbps" - assert release.extra['bitrate_value'] == 128 - assert release.extra['posted_date'] == "01 Jan 2024" - assert release.extra['title_raw'] == "Test Book - Test Author" - assert release.extra['author'] == "Test Author" - assert release.extra['language_raw'] == "English" + assert release.extra["preview"] == "https://example.com/cover.jpg" + assert release.extra["detail_url"] == "https://audiobookbay.lu/abss/test-book/" + assert release.extra["bitrate"] == "128 Kbps" + assert release.extra["bitrate_value"] == 128 + assert release.extra["posted_date"] == "01 Jan 2024" + assert release.extra["title_raw"] == "Test Book - Test Author" + assert release.extra["author"] == "Test Author" + assert release.extra["language_raw"] == "English" def test_split_title_and_author(self): """Test title/author parsing from ABB title patterns.""" - assert _split_title_and_author("Book Title - Author Name") == ( - "Book Title", "Author Name" - ) + assert _split_title_and_author("Book Title - Author Name") == ("Book Title", "Author Name") assert _split_title_and_author("Book Title - Author Name - Narrator") == ( - "Book Title - Author Name", "Narrator" + "Book Title - Author Name", + "Narrator", ) assert _split_title_and_author("Book Title") == ("Book Title", None) assert _split_title_and_author(" Book Title - Author Name ") == ( - "Book Title", "Author Name" + "Book Title", + "Author Name", ) - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_handles_scraper_exception(self, mock_search): """Test that scraper exceptions are handled gracefully.""" mock_search.side_effect = Exception("Scraper error") - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -387,26 +392,26 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="audiobook") - + assert results == [] - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_handles_invalid_result(self, mock_search): """Test that invalid results are skipped.""" mock_search.return_value = [ { - 'title': 'Relevant Book', - 'link': 'https://audiobookbay.lu/abss/valid/', - 'format': 'M4B', + "title": "Relevant Book", + "link": "https://audiobookbay.lu/abss/valid/", + "format": "M4B", }, { # Missing required fields - 'title': 'Relevant But Invalid', + "title": "Relevant But Invalid", }, ] - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -421,28 +426,29 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Relevant", author="Author")], grouped_title_variants=[], ) - + results = source.search(book, plan, content_type="audiobook") - + # Should only include valid result assert len(results) == 1 assert results[0].title == "Relevant Book" - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_config_hostname(self, mock_search, monkeypatch): """Test that custom hostname is used from config.""" mock_search.return_value = [] - + def mock_get(key, default=None): if key == "ABB_HOSTNAME": return "audiobookbay.is" if key == "ABB_PAGE_LIMIT": return 3 return default - + import shelfmark.release_sources.audiobookbay.source as source_module + monkeypatch.setattr(source_module.config, "get", mock_get) - + source = AudiobookBaySource() book = BookMetadata( provider="test", @@ -457,14 +463,14 @@ class TestAudiobookBaySource: title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")], grouped_title_variants=[], ) - - source.search(book, plan, content_type="audiobook") - - call_args = mock_search.call_args - assert call_args.kwargs['hostname'] == "audiobookbay.is" - assert call_args.kwargs['max_pages'] == 3 - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + source.search(book, plan, content_type="audiobook") + + call_args = mock_search.call_args + assert call_args.kwargs["hostname"] == "audiobookbay.is" + assert call_args.kwargs["max_pages"] == 3 + + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_exact_phrase_setting_forwarded(self, mock_search, monkeypatch): """Test that exact phrase setting is forwarded to scraper search.""" mock_search.return_value = [ @@ -488,6 +494,7 @@ class TestAudiobookBaySource: return default import shelfmark.release_sources.audiobookbay.source as source_module + monkeypatch.setattr(source_module.config, "get", mock_get) source = AudiobookBaySource() @@ -510,7 +517,7 @@ class TestAudiobookBaySource: call_args = mock_search.call_args_list[0] assert call_args.kwargs["exact_phrase"] is True - @patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay') + @patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay") def test_search_falls_back_to_broad_when_exact_finds_no_results(self, mock_search, monkeypatch): """Test fallback to broad search when exact phrase search has no results.""" mock_search.side_effect = [ @@ -537,6 +544,7 @@ class TestAudiobookBaySource: return default import shelfmark.release_sources.audiobookbay.source as source_module + monkeypatch.setattr(source_module.config, "get", mock_get) source = AudiobookBaySource() @@ -567,7 +575,7 @@ class TestAudiobookBaySource: """Test column configuration.""" source = AudiobookBaySource() config = source.get_column_config() - + assert config is not None assert len(config.columns) == 4 column_keys = [col.key for col in config.columns] diff --git a/tests/audiobookbay/test_utils.py b/tests/audiobookbay/test_utils.py index 599107b..38a72ab 100644 --- a/tests/audiobookbay/test_utils.py +++ b/tests/audiobookbay/test_utils.py @@ -2,8 +2,6 @@ Tests for AudiobookBay utility functions. """ -import pytest - from shelfmark.release_sources.audiobookbay.utils import parse_size @@ -25,28 +23,28 @@ class TestParseSize: def test_parse_size_megabytes(self): """Test parsing megabyte sizes.""" - assert parse_size("1 MB") == 1024 ** 2 - assert parse_size("500 MB") == 500 * (1024 ** 2) - assert parse_size("1.5 MB") == int(1.5 * (1024 ** 2)) - assert parse_size("500.00 MBs") == int(500.00 * (1024 ** 2)) # Handles "MBs" suffix + assert parse_size("1 MB") == 1024**2 + assert parse_size("500 MB") == 500 * (1024**2) + assert parse_size("1.5 MB") == int(1.5 * (1024**2)) + assert parse_size("500.00 MBs") == int(500.00 * (1024**2)) # Handles "MBs" suffix def test_parse_size_gigabytes(self): """Test parsing gigabyte sizes.""" - assert parse_size("1 GB") == 1024 ** 3 - assert parse_size("11.68 GB") == int(11.68 * (1024 ** 3)) - assert parse_size("1.01 GBs") == int(1.01 * (1024 ** 3)) # Handles "GBs" suffix + assert parse_size("1 GB") == 1024**3 + assert parse_size("11.68 GB") == int(11.68 * (1024**3)) + assert parse_size("1.01 GBs") == int(1.01 * (1024**3)) # Handles "GBs" suffix def test_parse_size_terabytes(self): """Test parsing terabyte sizes.""" - assert parse_size("1 TB") == 1024 ** 4 - assert parse_size("2.5 TB") == int(2.5 * (1024 ** 4)) + assert parse_size("1 TB") == 1024**4 + assert parse_size("2.5 TB") == int(2.5 * (1024**4)) def test_parse_size_case_insensitive(self): """Test that size parsing is case insensitive.""" - assert parse_size("1 gb") == 1024 ** 3 - assert parse_size("1 Gb") == 1024 ** 3 - assert parse_size("1 GB") == 1024 ** 3 - assert parse_size("1 gbs") == 1024 ** 3 + assert parse_size("1 gb") == 1024**3 + assert parse_size("1 Gb") == 1024**3 + assert parse_size("1 GB") == 1024**3 + assert parse_size("1 gbs") == 1024**3 def test_parse_size_none(self): """Test that None returns None.""" @@ -64,5 +62,5 @@ class TestParseSize: def test_parse_size_with_whitespace(self): """Test parsing with various whitespace.""" - assert parse_size(" 1 GB ") == 1024 ** 3 - assert parse_size("1.5\tMB") == int(1.5 * (1024 ** 2)) + assert parse_size(" 1 GB ") == 1024**3 + assert parse_size("1.5\tMB") == int(1.5 * (1024**2)) diff --git a/tests/bypass/test_external_bypasser.py b/tests/bypass/test_external_bypasser.py new file mode 100644 index 0000000..15d0710 --- /dev/null +++ b/tests/bypass/test_external_bypasser.py @@ -0,0 +1,103 @@ +"""Tests for the external bypasser flow.""" + + +class _FakeResponse: + def __init__(self, payload: dict) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +def test_fetch_via_bypasser_posts_expected_payload_and_uses_ssl_verify(monkeypatch): + import shelfmark.bypass.external_bypasser as external_bypasser + + calls: list[dict] = [] + + def fake_get(key, default=""): + values = { + "EXT_BYPASSER_URL": "https://bypass.example", + "EXT_BYPASSER_PATH": "/v1", + "EXT_BYPASSER_TIMEOUT": 60000, + } + return values.get(key, default) + + def fake_post(url: str, **kwargs): + calls.append({"url": url, **kwargs}) + return _FakeResponse( + { + "status": "ok", + "message": "done", + "solution": {"response": "ok"}, + } + ) + + monkeypatch.setattr(external_bypasser.config, "get", fake_get) + monkeypatch.setattr(external_bypasser.requests, "post", fake_post) + monkeypatch.setattr(external_bypasser, "get_ssl_verify", lambda _url: False) + + assert external_bypasser._fetch_via_bypasser("https://example.com/book") == "ok" + assert calls == [ + { + "url": "https://bypass.example/v1", + "headers": {"Content-Type": "application/json"}, + "json": { + "cmd": "request.get", + "url": "https://example.com/book", + "maxTimeout": 60000, + }, + "timeout": (10, 75.0), + "verify": False, + } + ] + + +def test_get_bypassed_page_retries_and_rotates_selector_between_attempts(monkeypatch): + import shelfmark.bypass.external_bypasser as external_bypasser + + class FakeRng: + def random(self) -> float: + return 0.0 + + class FakeSelector: + def __init__(self) -> None: + self.current_base = "https://mirror-one.example" + self.rewrite_calls: list[str] = [] + self.rotate_calls = 0 + + def rewrite(self, url: str) -> str: + self.rewrite_calls.append(url) + return url.replace("https://orig.example", self.current_base, 1) + + def next_mirror_or_rotate_dns(self) -> tuple[str | None, str]: + self.rotate_calls += 1 + self.current_base = "https://mirror-two.example" + return self.current_base, "mirror" + + fetch_calls: list[str] = [] + sleeps: list[float] = [] + responses = [None, "ok"] + + def fake_fetch(url: str) -> str | None: + fetch_calls.append(url) + return responses.pop(0) + + monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", fake_fetch) + monkeypatch.setattr( + external_bypasser, "_sleep_with_cancellation", lambda seconds, _flag: sleeps.append(seconds) + ) + monkeypatch.setattr(external_bypasser, "_RNG", FakeRng()) + + selector = FakeSelector() + result = external_bypasser.get_bypassed_page("https://orig.example/book", selector=selector) + + assert result == "ok" + assert fetch_calls == [ + "https://mirror-one.example/book", + "https://mirror-two.example/book", + ] + assert selector.rotate_calls == 1 + assert sleeps == [1.0] diff --git a/tests/bypass/test_internal_bypasser.py b/tests/bypass/test_internal_bypasser.py index b21ac30..ebae022 100644 --- a/tests/bypass/test_internal_bypasser.py +++ b/tests/bypass/test_internal_bypasser.py @@ -199,7 +199,9 @@ def test_get_bypassed_page_retries_next_mirror_after_runtime_error(monkeypatch): raise RuntimeError("browser hiccup") return "ok" - monkeypatch.setattr(internal_bypasser, "_try_with_cached_cookies", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + internal_bypasser, "_try_with_cached_cookies", lambda *_args, **_kwargs: None + ) monkeypatch.setattr(internal_bypasser, "get", _fake_get) selector = FakeSelector() diff --git a/tests/config/test_dns_settings_live_apply.py b/tests/config/test_dns_settings_live_apply.py index a1b0414..c4af760 100644 --- a/tests/config/test_dns_settings_live_apply.py +++ b/tests/config/test_dns_settings_live_apply.py @@ -4,7 +4,9 @@ def test_update_settings_network_logs_dns_apply_failure(monkeypatch): from shelfmark.core.config import config as config_obj from shelfmark.core.settings_registry import update_settings - monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True) + monkeypatch.setattr( + "shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True + ) monkeypatch.setattr(config_obj, "refresh", lambda: None) import shelfmark.download.network as network diff --git a/tests/config/test_docker_volumes.py b/tests/config/test_docker_volumes.py index 155b7a7..dd96148 100644 --- a/tests/config/test_docker_volumes.py +++ b/tests/config/test_docker_volumes.py @@ -1,645 +1,106 @@ -""" -Docker volume and filesystem edge case tests. +"""Tests for config file persistence, corruption recovery, and permissions.""" -These tests verify the application handles various Docker volume configurations -correctly, including named volumes, bind mounts, permission issues, and -edge cases that commonly cause issues in containerized deployments. +from __future__ import annotations -Run with: uv run pytest tests/config/test_docker_volumes.py -v -""" - -import json import os import stat -import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock import pytest -# ============================================================================= -# Fresh Install / Empty Volume Tests -# ============================================================================= +def test_save_config_file_merges_existing_values_and_preserves_unknown_keys(tmp_path): + from shelfmark.core.settings_registry import load_config_file, save_config_file - -class TestFreshInstall: - """Tests simulating a fresh install with empty volumes.""" - - def test_config_dir_created_on_first_save(self): - """Config directory and plugins subdirectory should be created on first save.""" - from shelfmark.core.settings_registry import save_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) / "config" - # Directory doesn't exist yet - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = save_config_file("test_plugin", {"key": "value"}) - - assert result is True - assert config_dir.exists() - assert (config_dir / "plugins").exists() - assert (config_dir / "plugins" / "test_plugin.json").exists() - - def test_general_settings_saved_to_settings_json(self): - """General settings should go to settings.json, not plugins folder.""" - from shelfmark.core.settings_registry import ( - save_config_file, - _get_config_file_path, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - path = _get_config_file_path("general") - assert path == config_dir / "settings.json" - - save_config_file("general", {"key": "value"}) - assert (config_dir / "settings.json").exists() - - def test_nested_config_directories_created(self): - """Deeply nested config paths should be created with parents=True.""" - from shelfmark.core.settings_registry import _ensure_config_dir - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) / "deeply" / "nested" / "config" - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - _ensure_config_dir("test_plugin") - - assert (config_dir / "plugins").exists() - - def test_empty_config_returns_defaults(self): - """Loading from empty/missing config should return empty dict.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): - result = load_config_file("nonexistent") - - assert result == {} - - -# ============================================================================= -# Corrupted / Invalid Config File Tests -# ============================================================================= - - -class TestCorruptedConfig: - """Tests for handling corrupted or invalid config files.""" - - def test_invalid_json_returns_empty_dict(self): - """Invalid JSON in config file should return empty dict, not crash.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Write invalid JSON - (plugins_dir / "broken.json").write_text("{ invalid json }") - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = load_config_file("broken") - - assert result == {} - - def test_empty_json_file_returns_empty_dict(self): - """Empty JSON file should return empty dict.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Write empty file - (plugins_dir / "empty.json").write_text("") - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = load_config_file("empty") - - # Empty file is invalid JSON, should return {} - assert result == {} - - def test_partial_json_write_recovery(self): - """Config should handle partially written JSON files.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Simulate interrupted write - (plugins_dir / "partial.json").write_text('{"key": "val') - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = load_config_file("partial") - - assert result == {} - - def test_null_bytes_in_config_file(self): - """Config with null bytes should be handled gracefully.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Write file with null bytes - (plugins_dir / "nullbytes.json").write_bytes(b'{"key": "value\x00"}') - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - # Should either parse (ignoring null) or return empty - result = load_config_file("nullbytes") - # Just verify it doesn't crash - assert isinstance(result, dict) - - def test_wrong_type_in_config(self): - """Config with array instead of object should be handled.""" - from shelfmark.core.settings_registry import load_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Write array instead of object - (plugins_dir / "wrongtype.json").write_text('["item1", "item2"]') - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = load_config_file("wrongtype") - # Should return the parsed content (a list) or handle gracefully - # Current implementation returns whatever json.load returns - assert isinstance(result, (dict, list)) - - -# ============================================================================= -# Permission Tests (Docker PUID/PGID scenarios) -# ============================================================================= - - -class TestPermissions: - """Tests for permission-related scenarios.""" - - @pytest.mark.skipif( - os.geteuid() == 0, - reason="Permission tests don't work when running as root" + config_dir = tmp_path + plugins_dir = config_dir / "plugins" + plugins_dir.mkdir(parents=True) + (plugins_dir / "downloads.json").write_text( + '{"existing": "value", "unknown": {"nested": true}}' ) - def test_read_only_config_dir_save_fails_gracefully(self): - """Saving to read-only config dir should fail gracefully, not crash.""" - from shelfmark.core.settings_registry import save_config_file - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) / "readonly" - config_dir.mkdir() - os.chmod(config_dir, stat.S_IRUSR | stat.S_IXUSR) # r-x - - try: - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = save_config_file("test", {"key": "value"}) - - assert result is False - finally: - os.chmod(config_dir, stat.S_IRWXU) - - @pytest.mark.skipif( - os.geteuid() == 0, - reason="Permission tests don't work when running as root" - ) - def test_read_only_config_file_save_fails_gracefully(self): - """Saving when config file is read-only should fail gracefully.""" - from shelfmark.core.settings_registry import save_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - config_file = plugins_dir / "readonly.json" - config_file.write_text('{"existing": "value"}') - os.chmod(config_file, stat.S_IRUSR) # Read-only - - try: - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = save_config_file("readonly", {"new": "value"}) - - assert result is False - finally: - os.chmod(config_file, stat.S_IRWXU) - - def test_config_dir_exists_check(self): - """_is_config_dir_writable should correctly detect writable dirs.""" - from shelfmark.config.env import _is_config_dir_writable - - with tempfile.TemporaryDirectory() as tmpdir: - writable_dir = Path(tmpdir) / "writable" - writable_dir.mkdir() - - with patch("shelfmark.config.env.CONFIG_DIR", writable_dir): - assert _is_config_dir_writable() is True - - def test_config_dir_not_exists(self): - """_is_config_dir_writable should return False for non-existent dir.""" - from shelfmark.config.env import _is_config_dir_writable - - with patch( - "shelfmark.config.env.CONFIG_DIR", - Path("/nonexistent/path/that/does/not/exist") - ): - assert _is_config_dir_writable() is False - - -# ============================================================================= -# Path Edge Cases -# ============================================================================= - - -class TestPathEdgeCases: - """Tests for edge cases in path handling.""" - - @pytest.mark.parametrize("tab_name", ["../escape", "nested/plugin", "..", "."]) - def test_plugin_tab_name_path_traversal_rejected(self, tab_name): - """Plugin tab names must stay inside the plugins config directory.""" - from shelfmark.core.settings_registry import _get_config_file_path - - with tempfile.TemporaryDirectory() as tmpdir: - with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): - with pytest.raises(ValueError, match="Invalid tab name"): - _get_config_file_path(tab_name) - - def test_config_dir_with_spaces(self): - """Config directory with spaces in path should work.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) / "path with spaces" / "config" - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - save_config_file("test", {"key": "value"}) - result = load_config_file("test") - - assert result == {"key": "value"} - - def test_config_dir_with_unicode(self): - """Config directory with unicode characters should work.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) / "配置文件夹" / "config" - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - save_config_file("test", {"key": "value"}) - result = load_config_file("test") - - assert result == {"key": "value"} - - def test_config_with_unicode_values(self): - """Config values with unicode should be preserved.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - save_config_file("test", { - "title": "日本語タイトル", - "author": "Автор книги", - "emoji": "📚🎉", - }) - result = load_config_file("test") - - assert result["title"] == "日本語タイトル" - assert result["author"] == "Автор книги" - assert result["emoji"] == "📚🎉" - - def test_very_long_plugin_name(self): - """Very long plugin names should be handled.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - long_name = "a" * 200 # Very long name - - with tempfile.TemporaryDirectory() as tmpdir: - with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): - # This might fail on some filesystems with path length limits - try: - result = save_config_file(long_name, {"key": "value"}) - if result: - loaded = load_config_file(long_name) - assert loaded == {"key": "value"} - except OSError: - # Expected on filesystems with path length limits - pass - - -# ============================================================================= -# Config File Merging Tests -# ============================================================================= - - -class TestConfigMerging: - """Tests for config file merging behavior.""" - - def test_save_merges_with_existing(self): - """Saving should merge with existing values, not replace.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Write initial config - (plugins_dir / "merge.json").write_text('{"existing": "value", "old": "data"}') - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - save_config_file("merge", {"new": "value", "existing": "updated"}) - result = load_config_file("merge") - - assert result["old"] == "data" # Preserved - assert result["new"] == "value" # Added - assert result["existing"] == "updated" # Updated - - def test_save_handles_nested_objects(self): - """Saving nested objects should work correctly.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - save_config_file("nested", { - "level1": { - "level2": { - "value": "deep" - } - }, - "list": [1, 2, 3], - }) - result = load_config_file("nested") - - assert result["level1"]["level2"]["value"] == "deep" - assert result["list"] == [1, 2, 3] - - -# ============================================================================= -# Cross-Filesystem Tests (TMP_DIR vs INGEST_DIR) -# ============================================================================= - - -class TestCrossFilesystem: - """Tests for cross-filesystem scenarios.""" - - def test_staging_and_ingest_same_filesystem(self): - """When TMP_DIR and INGEST_DIR are on same filesystem, move is used.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmp_dir = Path(tmpdir) / "tmp" - ingest_dir = Path(tmpdir) / "ingest" - tmp_dir.mkdir() - ingest_dir.mkdir() - - # Both on same filesystem - assert os.stat(tmp_dir).st_dev == os.stat(ingest_dir).st_dev - - def test_detect_cross_filesystem(self): - """Cross-filesystem detection uses same_filesystem() at runtime.""" - # Detection is done lazily by same_filesystem() in core/naming.py - # when hardlinking is attempted, not at startup - pass - - -# ============================================================================= -# Startup Directory Creation Tests -# ============================================================================= - - -class TestStartupDirectoryCreation: - """Tests for directory creation during startup.""" - - def test_tmp_dir_created_without_parents(self): - """TMP_DIR.mkdir(exist_ok=True) needs parent to exist.""" - # This documents a potential issue: mkdir(exist_ok=True) without - # parents=True will fail if parent doesn't exist - - with tempfile.TemporaryDirectory() as tmpdir: - # Parent exists - tmp_dir = Path(tmpdir) / "tmp" - tmp_dir.mkdir(exist_ok=True) - assert tmp_dir.exists() - - # Parent doesn't exist - would fail without parents=True - nested = Path(tmpdir) / "nonexistent" / "deep" / "tmp" - with pytest.raises(FileNotFoundError): - nested.mkdir(exist_ok=True) - - # With parents=True it works - nested.mkdir(parents=True, exist_ok=True) - assert nested.exists() - - def test_ingest_dir_created_without_parents(self): - """INGEST_DIR.mkdir(exist_ok=True) needs parent to exist.""" - # Same potential issue as TMP_DIR - with tempfile.TemporaryDirectory() as tmpdir: - ingest = Path(tmpdir) / "ingest" - ingest.mkdir(exist_ok=True) - assert ingest.exists() - - -# ============================================================================= -# Named Volume vs Bind Mount Simulation -# ============================================================================= - - -class TestVolumeTypes: - """Tests simulating named volume vs bind mount differences.""" - - def test_empty_named_volume_scenario(self): - """ - Simulate named volume: directory exists but is empty. - - Named volumes are created by Docker as empty directories owned by root. - The application should handle this gracefully. - """ - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - # Simulate named volume: empty directory exists - config_dir = Path(tmpdir) / "config" - config_dir.mkdir() - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - # Should create plugins subdirectory and save - result = save_config_file("test", {"key": "value"}) - assert result is True - - loaded = load_config_file("test") - assert loaded == {"key": "value"} - - def test_bind_mount_with_existing_files(self): - """ - Simulate bind mount: directory has existing files from host. - - Bind mounts may have existing config from a previous installation. - """ - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Existing config from previous install - (plugins_dir / "prowlarr_clients.json").write_text(json.dumps({ - "PROWLARR_TORRENT_CLIENT": "qbittorrent", - "QBITTORRENT_URL": "http://old-host:8080", - })) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - # New save should merge - save_config_file("prowlarr_clients", { - "QBITTORRENT_URL": "http://new-host:8080", - }) - - result = load_config_file("prowlarr_clients") - - # Should have merged - assert result["PROWLARR_TORRENT_CLIENT"] == "qbittorrent" - assert result["QBITTORRENT_URL"] == "http://new-host:8080" - - def test_volume_with_only_partial_structure(self): - """ - Simulate volume with partial directory structure. - - User might manually create /config but not /config/plugins. - """ - from shelfmark.core.settings_registry import save_config_file - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - # Only config dir exists, not plugins subdir - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = save_config_file("test", {"key": "value"}) - - assert result is True - assert (config_dir / "plugins").exists() - assert (config_dir / "plugins" / "test.json").exists() - - -# ============================================================================= -# Race Condition and Concurrent Access Tests -# ============================================================================= - - -class TestConcurrentAccess: - """Tests for concurrent config access scenarios.""" - - def test_simultaneous_saves_dont_corrupt(self): - """Multiple saves should not corrupt the config file.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - import threading - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - - results = [] - - def save_value(key, value): - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - result = save_config_file("concurrent", {key: value}) - results.append(result) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - threads = [ - threading.Thread(target=save_value, args=(f"key{i}", f"value{i}")) - for i in range(5) - ] - - for t in threads: - t.start() - for t in threads: - t.join() - - # All saves should succeed - assert all(results) - - # File should be valid JSON - final = load_config_file("concurrent") - assert isinstance(final, dict) - - -# ============================================================================= -# Config Backup/Migration Tests -# ============================================================================= - - -class TestConfigMigration: - """Tests for config migration scenarios.""" - - def test_old_format_config_upgrade(self): - """ - Application should handle config from older versions. - - This is a placeholder for version-specific migration tests. - """ - pass - - def test_config_with_unknown_keys(self): - """Config with unknown keys should be preserved.""" - from shelfmark.core.settings_registry import ( - save_config_file, - load_config_file, - ) - - with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) - plugins_dir = config_dir / "plugins" - plugins_dir.mkdir(parents=True) - - # Config with keys that don't exist in current schema - (plugins_dir / "future.json").write_text(json.dumps({ - "KNOWN_KEY": "value", - "FUTURE_KEY_V2": "future_value", - "ANOTHER_UNKNOWN": 123, - })) - - with patch("shelfmark.config.env.CONFIG_DIR", config_dir): - # Save should preserve unknown keys - save_config_file("future", {"KNOWN_KEY": "updated"}) - result = load_config_file("future") - - assert result["KNOWN_KEY"] == "updated" - assert result["FUTURE_KEY_V2"] == "future_value" - assert result["ANOTHER_UNKNOWN"] == 123 + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", config_dir) + assert save_config_file("downloads", {"existing": "updated", "new": "value"}) is True + result = load_config_file("downloads") + + assert result == { + "existing": "updated", + "new": "value", + "unknown": {"nested": True}, + } + + +@pytest.mark.parametrize( + ("filename", "contents"), + [ + ("missing", None), + ("empty", ""), + ("broken", "{ invalid json }"), + ("partial", '{"key": "value"'), + ], +) +def test_load_config_file_returns_empty_dict_for_missing_or_corrupted_files( + tmp_path: Path, + filename: str, + contents: str | None, +): + from shelfmark.core.settings_registry import load_config_file + + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir(parents=True) + if contents is not None: + (plugins_dir / f"{filename}.json").write_text(contents) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", tmp_path) + result = load_config_file(filename) + + assert result == {} + + +@pytest.mark.parametrize( + "tab_name", + ["../escape", "nested/plugin", "..", "."], +) +def test_get_config_file_path_rejects_path_traversal(tmp_path: Path, tab_name: str): + from shelfmark.core.settings_registry import _get_config_file_path + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", tmp_path) + with pytest.raises(ValueError, match="Invalid tab name"): + _get_config_file_path(tab_name) + + +def test_is_config_dir_writable_tracks_directory_state(tmp_path: Path): + from shelfmark.config.env import _is_config_dir_writable + + writable_dir = tmp_path / "writable" + writable_dir.mkdir() + missing_dir = tmp_path / "missing" + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", writable_dir) + assert _is_config_dir_writable() is True + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", missing_dir) + assert _is_config_dir_writable() is False + + +@pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, reason="Permission tests are unreliable as root" +) +def test_save_config_file_returns_false_when_config_dir_is_not_writable(tmp_path: Path): + from shelfmark.core.settings_registry import save_config_file + + config_dir = tmp_path / "readonly" + config_dir.mkdir() + config_dir.chmod(stat.S_IRUSR | stat.S_IXUSR) + + try: + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", config_dir) + assert save_config_file("downloads", {"key": "value"}) is False + finally: + config_dir.chmod(stat.S_IRWXU) diff --git a/tests/config/test_download_legacy_migration.py b/tests/config/test_download_legacy_migration.py new file mode 100644 index 0000000..2b6d4b8 --- /dev/null +++ b/tests/config/test_download_legacy_migration.py @@ -0,0 +1,108 @@ +"""Tests for legacy download-setting migration into the current config model.""" + +from unittest.mock import MagicMock + + +def test_migrate_legacy_download_settings_from_ingest_dir_and_use_book_title(monkeypatch): + import shelfmark.core.settings_registry as registry + + saved: list[tuple[str, dict[str, object]]] = [] + + monkeypatch.setattr( + registry, + "load_config_file", + lambda tab_name: ( + { + "INGEST_DIR": "/legacy/books", + "USE_BOOK_TITLE": True, + "TORRENT_HARDLINK": True, + } + if tab_name == "downloads" + else {} + ), + ) + monkeypatch.setattr( + registry, + "save_config_file", + lambda tab_name, values: saved.append((tab_name, values)) or True, + ) + + registry.migrate_legacy_settings() + + assert saved == [ + ( + "downloads", + { + "DESTINATION": "/legacy/books", + "FILE_ORGANIZATION": "rename", + "HARDLINK_TORRENTS": True, + "HARDLINK_TORRENTS_AUDIOBOOK": True, + }, + ) + ] + + +def test_migrate_legacy_download_settings_moves_content_type_routing(monkeypatch): + import shelfmark.core.settings_registry as registry + + saves = MagicMock(return_value=True) + + monkeypatch.setattr( + registry, + "load_config_file", + lambda tab_name: ( + { + "INGEST_DIR": "/legacy/books", + "USE_BOOK_TITLE": False, + "USE_CONTENT_TYPE_DIRECTORIES": True, + "INGEST_DIR_BOOK_FICTION": "/legacy/fiction", + "INGEST_DIR_COMIC_BOOK": "/legacy/comics", + } + if tab_name == "downloads" + else {} + ), + ) + monkeypatch.setattr(registry, "save_config_file", saves) + + registry.migrate_legacy_settings() + + saves.assert_any_call( + "downloads", + { + "DESTINATION": "/legacy/books", + "FILE_ORGANIZATION": "none", + }, + ) + saves.assert_any_call( + "download_sources", + { + "AA_CONTENT_TYPE_ROUTING": True, + "AA_CONTENT_TYPE_DIR_FICTION": "/legacy/fiction", + "AA_CONTENT_TYPE_DIR_COMIC": "/legacy/comics", + }, + ) + + +def test_migrate_legacy_download_settings_ignores_pre_release_processing_mode_keys(monkeypatch): + import shelfmark.core.settings_registry as registry + + saves = MagicMock(return_value=True) + + monkeypatch.setattr( + registry, + "load_config_file", + lambda tab_name: ( + { + "PROCESSING_MODE": "library", + "LIBRARY_PATH": "/library", + "LIBRARY_TEMPLATE": "{Author}/{Title}", + } + if tab_name == "downloads" + else {} + ), + ) + monkeypatch.setattr(registry, "save_config_file", saves) + + registry.migrate_legacy_settings() + + saves.assert_not_called() diff --git a/tests/config/test_download_settings.py b/tests/config/test_download_settings.py index 634b30d..8bd0c66 100644 --- a/tests/config/test_download_settings.py +++ b/tests/config/test_download_settings.py @@ -45,7 +45,9 @@ def test_download_settings_email_recipient_field_uses_default_label(): from shelfmark.config.settings import download_settings fields = download_settings() - email_field = next(field for field in fields if getattr(field, "key", None) == "EMAIL_RECIPIENT") + email_field = next( + field for field in fields if getattr(field, "key", None) == "EMAIL_RECIPIENT" + ) assert email_field.label == "Default Email Recipient" assert "Optional fallback" in email_field.description @@ -55,7 +57,9 @@ def test_download_settings_booklore_destination_field_defaults_to_library(): from shelfmark.config.settings import download_settings fields = download_settings() - destination_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_DESTINATION") + destination_field = next( + field for field in fields if getattr(field, "key", None) == "BOOKLORE_DESTINATION" + ) assert destination_field.default == "library" option_values = {option["value"] for option in destination_field.options} @@ -67,9 +71,15 @@ def test_download_settings_grimmory_copy_is_exposed_in_ui_metadata(): fields = download_settings() - output_mode_field = next(field for field in fields if getattr(field, "key", None) == "BOOKS_OUTPUT_MODE") - grimmory_option = next(option for option in output_mode_field.options if option["value"] == "booklore") - heading_field = next(field for field in fields if getattr(field, "key", None) == "booklore_heading") + output_mode_field = next( + field for field in fields if getattr(field, "key", None) == "BOOKS_OUTPUT_MODE" + ) + grimmory_option = next( + option for option in output_mode_field.options if option["value"] == "booklore" + ) + heading_field = next( + field for field in fields if getattr(field, "key", None) == "booklore_heading" + ) url_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_HOST") assert grimmory_option["label"] == "Grimmory (API)" @@ -83,8 +93,12 @@ def test_download_settings_booklore_library_and_path_depend_on_library_destinati from shelfmark.config.settings import download_settings fields = download_settings() - library_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_LIBRARY_ID") - path_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_PATH_ID") + library_field = next( + field for field in fields if getattr(field, "key", None) == "BOOKLORE_LIBRARY_ID" + ) + path_field = next( + field for field in fields if getattr(field, "key", None) == "BOOKLORE_PATH_ID" + ) assert library_field.show_when == [ {"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, @@ -100,7 +114,9 @@ def test_download_settings_destination_test_buttons_exist(): from shelfmark.config.settings import download_settings fields = download_settings() - books_button = next(field for field in fields if getattr(field, "key", None) == "test_destination") + books_button = next( + field for field in fields if getattr(field, "key", None) == "test_destination" + ) audiobook_button = next( field for field in fields if getattr(field, "key", None) == "test_destination_audiobook" ) @@ -191,7 +207,9 @@ def test_test_books_destination_requires_value(): assert result["message"] == "Books destination is required" -def test_test_books_destination_uses_persisted_value_when_current_values_missing(monkeypatch, tmp_path): +def test_test_books_destination_uses_persisted_value_when_current_values_missing( + monkeypatch, tmp_path +): from shelfmark.config.download_settings_handlers import check_books_destination from shelfmark.core.config import config diff --git a/tests/config/test_entrypoint_permissions.py b/tests/config/test_entrypoint_permissions.py index fdb9236..a13162b 100644 --- a/tests/config/test_entrypoint_permissions.py +++ b/tests/config/test_entrypoint_permissions.py @@ -114,7 +114,10 @@ def test_entrypoint_rejects_tor_in_non_root_mode(tmp_path): assert result.returncode == 1 assert "USING_TOR=true requires the container to start as root." in result.stderr - assert "Non-root mode skips the privileged filesystem and network setup Tor depends on." in result.stderr + assert ( + "Non-root mode skips the privileged filesystem and network setup Tor depends on." + in result.stderr + ) def test_entrypoint_non_root_mode_runs_with_stub_gunicorn(tmp_path): @@ -142,5 +145,7 @@ def test_entrypoint_non_root_mode_requires_writable_config_dir(tmp_path): readonly_config_dir.chmod(0o755) assert result.returncode == 1 - assert f"Config directory is not writable in non-root mode: {readonly_config_dir}" in result.stdout + assert ( + f"Config directory is not writable in non-root mode: {readonly_config_dir}" in result.stdout + ) assert "Prepare ownership outside the container" in result.stdout diff --git a/tests/config/test_environment.py b/tests/config/test_environment.py index 3ba0234..e093a73 100644 --- a/tests/config/test_environment.py +++ b/tests/config/test_environment.py @@ -7,16 +7,14 @@ configuration settings, environment variables, and Docker setups. Run with: uv run pytest tests/config/test_environment.py -v """ -import json +import importlib import os -import shutil import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest - # ============================================================================= # Directory Setup Tests # ============================================================================= @@ -57,13 +55,9 @@ class TestDirectorySetup: from shelfmark.download.staging import get_staging_path with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "shelfmark.config.env.TMP_DIR", Path(tmpdir) - ): + with patch("shelfmark.config.env.TMP_DIR", Path(tmpdir)): # Task ID with URL-like characters - path = get_staging_path( - "https://example.com/book?id=123&format=epub", "epub" - ) + path = get_staging_path("https://example.com/book?id=123&format=epub", "epub") assert path.suffix == ".epub" assert path.parent == Path(tmpdir) @@ -77,9 +71,7 @@ class TestDirectorySetup: from shelfmark.download.staging import get_staging_path with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "shelfmark.config.env.TMP_DIR", Path(tmpdir) - ): + with patch("shelfmark.config.env.TMP_DIR", Path(tmpdir)): path1 = get_staging_path("task1", "epub") path2 = get_staging_path("task1", ".epub") @@ -98,6 +90,7 @@ class TestSupportedFormats: def test_default_supported_formats(self): """Default formats should include common ebook formats.""" from shelfmark.core.config import config + # Ensure settings are refreshed to pick up defaults config.refresh() @@ -110,6 +103,7 @@ class TestSupportedFormats: def test_format_list_is_lowercase(self): """Format list should be normalized to lowercase.""" from shelfmark.core.config import config + # Ensure settings are refreshed to pick up defaults config.refresh() @@ -121,6 +115,7 @@ class TestSupportedFormats: def test_config_supported_formats_is_list(self): """Config should have SUPPORTED_FORMATS as a list.""" from shelfmark.core.config import config + # Ensure settings are refreshed to pick up defaults config.refresh() @@ -140,7 +135,7 @@ class TestContentTypeRouting: def test_get_ingest_dir_returns_path(self): """get_ingest_dir should return a Path for all content types.""" - from shelfmark.core.utils import get_ingest_dir, CONTENT_TYPES + from shelfmark.core.utils import CONTENT_TYPES, get_ingest_dir # Default (no content type) should return a Path default_path = get_ingest_dir() @@ -190,14 +185,12 @@ class TestSettingsSystem: def test_save_and_load_config(self): """Settings should persist to JSON files.""" from shelfmark.core.settings_registry import ( - save_config_file, load_config_file, + save_config_file, ) with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) - ): + with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): test_data = {"key1": "value1", "key2": 123, "key3": True} save_config_file("test_plugin", test_data) @@ -210,9 +203,7 @@ class TestSettingsSystem: from shelfmark.core.settings_registry import load_config_file with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) - ): + with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): loaded = load_config_file("nonexistent_plugin") assert loaded == {} @@ -222,13 +213,8 @@ class TestSettingsSystem: from shelfmark.core.config import config from shelfmark.core.settings_registry import save_config_file - # Get initial value - initial = config.get("TEST_REFRESH_KEY", "default") - with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) - ): + with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): save_config_file("test", {"TEST_REFRESH_KEY": "new_value"}) config.refresh() @@ -314,16 +300,22 @@ class TestArchiveHandling: class TestConfigValidation: """Tests for configuration validation and error handling.""" - def test_invalid_number_env_var_uses_default(self): - """Invalid numeric env vars should fall back to defaults.""" - # Test that int() parsing handles invalid values gracefully - # The env.py module uses int() which will raise ValueError - # This tests the expected behavior + def test_invalid_flask_port_env_var_raises_value_error_on_reload(self, monkeypatch): + """Invalid FLASK_PORT values should fail fast when env.py reloads.""" + import shelfmark.config.env as env_module - with patch.dict(os.environ, {"MAX_RETRY": "not_a_number"}): - # Importing with invalid env var should use default or raise - # This depends on implementation - test documents behavior - pass # Currently env.py will crash on invalid int + original_port = os.environ.get("FLASK_PORT") + + try: + monkeypatch.setenv("FLASK_PORT", "not_a_number") + with pytest.raises(ValueError, match="invalid literal for int"): + importlib.reload(env_module) + finally: + if original_port is None: + monkeypatch.delenv("FLASK_PORT", raising=False) + else: + monkeypatch.setenv("FLASK_PORT", original_port) + importlib.reload(env_module) def test_missing_required_directory_handling(self): """Application should handle missing directories gracefully.""" @@ -333,17 +325,14 @@ class TestConfigValidation: # Use a path that doesn't exist yet nonexistent = Path(tmpdir) / "deeply" / "nested" / "path" - with patch( - "shelfmark.config.env.TMP_DIR", nonexistent - ): - result = get_staging_dir() + with patch("shelfmark.config.env.TMP_DIR", nonexistent): + get_staging_dir() # Should have created the directory assert nonexistent.exists() @pytest.mark.skipif( - os.geteuid() == 0, - reason="Test skipped when running as root (chmod has no effect)" + os.geteuid() == 0, reason="Test skipped when running as root (chmod has no effect)" ) def test_config_dir_not_writable(self): """Application should handle read-only config directory.""" @@ -355,9 +344,7 @@ class TestConfigValidation: os.chmod(readonly_dir, 0o444) # Read-only try: - with patch( - "shelfmark.config.env.CONFIG_DIR", readonly_dir - ): + with patch("shelfmark.config.env.CONFIG_DIR", readonly_dir): result = _is_config_dir_writable() assert result is False finally: @@ -442,12 +429,28 @@ class TestDebugConfiguration: assert string_to_bool("true") is True assert string_to_bool("false") is False - def test_log_level_derived_from_debug(self): - """LOG_LEVEL should be derived from DEBUG setting.""" - # When DEBUG is True, LOG_LEVEL should be "DEBUG" - # When DEBUG is False, LOG_LEVEL should be "INFO" - # This is tested by checking the module logic - pass # The logic is in env.py: LOG_LEVEL = "DEBUG" if DEBUG else "INFO" + def test_log_level_derived_from_debug(self, monkeypatch): + """LOG_LEVEL should follow the effective DEBUG value on reload.""" + import shelfmark.config.env as env_module + + original_debug = os.environ.get("DEBUG") + + try: + monkeypatch.setenv("DEBUG", "true") + importlib.reload(env_module) + assert env_module.DEBUG is True + assert env_module.LOG_LEVEL == "DEBUG" + + monkeypatch.setenv("DEBUG", "false") + importlib.reload(env_module) + assert env_module.DEBUG is False + assert env_module.LOG_LEVEL == "INFO" + finally: + if original_debug is None: + monkeypatch.delenv("DEBUG", raising=False) + else: + monkeypatch.setenv("DEBUG", original_debug) + importlib.reload(env_module) # ============================================================================= @@ -461,6 +464,7 @@ class TestNetworkConfiguration: def test_proxy_settings_default(self): """Proxy settings should have sensible defaults.""" from shelfmark.core.config import config + config.refresh() # Default proxy mode should be 'none' (no proxy) @@ -486,6 +490,7 @@ class TestConcurrencyConfiguration: def test_max_concurrent_downloads_default(self): """MAX_CONCURRENT_DOWNLOADS should have a sensible default.""" from shelfmark.core.config import config + config.refresh() max_downloads = config.get("MAX_CONCURRENT_DOWNLOADS", 3) @@ -495,6 +500,7 @@ class TestConcurrencyConfiguration: def test_download_progress_interval_default(self): """DOWNLOAD_PROGRESS_UPDATE_INTERVAL should have a sensible default.""" from shelfmark.core.config import config + config.refresh() interval = config.get("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", 1) @@ -513,6 +519,7 @@ class TestCacheConfiguration: def test_metadata_cache_ttl_defaults(self): """Metadata cache TTLs should have sensible defaults.""" from shelfmark.core.config import config + config.refresh() search_ttl = config.get("METADATA_CACHE_SEARCH_TTL", 300) @@ -555,9 +562,7 @@ class TestFileCollisionHandling: # Create existing file with same name in staging (staging / "book.epub").write_text("existing") - with patch( - "shelfmark.config.env.TMP_DIR", staging - ): + with patch("shelfmark.config.env.TMP_DIR", staging): result = stage_file(source, "task1", copy=True) # Should have created a new file with suffix @@ -576,9 +581,7 @@ class TestFileCollisionHandling: source1 = Path(tmpdir) / "book1.epub" source1.write_text("content1") - with patch( - "shelfmark.config.env.TMP_DIR", staging - ): + with patch("shelfmark.config.env.TMP_DIR", staging): result1 = stage_file(source1, "task1", copy=True) assert source1.exists() # Original still exists @@ -588,9 +591,7 @@ class TestFileCollisionHandling: source2 = Path(tmpdir) / "book2.epub" source2.write_text("content2") - with patch( - "shelfmark.config.env.TMP_DIR", staging - ): + with patch("shelfmark.config.env.TMP_DIR", staging): result2 = stage_file(source2, "task2", copy=False) assert not source2.exists() # Original moved diff --git a/tests/config/test_mirror_settings_live_apply.py b/tests/config/test_mirror_settings_live_apply.py index 4cd1364..f63a8ba 100644 --- a/tests/config/test_mirror_settings_live_apply.py +++ b/tests/config/test_mirror_settings_live_apply.py @@ -8,7 +8,9 @@ def test_update_settings_mirrors_applies_aa_changes_live(monkeypatch): monkeypatch.delenv("AA_ADDITIONAL_URLS", raising=False) # Avoid writing to disk and avoid forcing a full config refresh in this unit test. - monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True) + monkeypatch.setattr( + "shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True + ) monkeypatch.setattr(config_obj, "refresh", lambda: None) called: dict[str, object] = {} @@ -32,7 +34,9 @@ def test_update_settings_mirrors_logs_live_apply_failure(monkeypatch): from shelfmark.core.config import config as config_obj from shelfmark.core.settings_registry import update_settings - monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True) + monkeypatch.setattr( + "shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True + ) monkeypatch.setattr(config_obj, "refresh", lambda: None) import shelfmark.download.network as network diff --git a/tests/config/test_oidc_settings.py b/tests/config/test_oidc_settings.py index ea3e31d..42639e3 100644 --- a/tests/config/test_oidc_settings.py +++ b/tests/config/test_oidc_settings.py @@ -6,17 +6,19 @@ show_when conditions, defaults, and field types. """ from shelfmark.core.settings_registry import ( - TextField, - PasswordField, CheckboxField, + PasswordField, TagListField, + TextField, ) def _reload_security_module(): """Reload security module to pick up patched values.""" import importlib + import shelfmark.config.security + importlib.reload(shelfmark.config.security) return shelfmark.config.security.security_settings() @@ -133,8 +135,7 @@ class TestOIDCFieldShowWhen: conditions = [show_when] # At least one condition should reference AUTH_METHOD=oidc has_oidc_condition = any( - c.get("field") == "AUTH_METHOD" and c.get("value") == "oidc" - for c in conditions + c.get("field") == "AUTH_METHOD" and c.get("value") == "oidc" for c in conditions ) assert has_oidc_condition, f"Field {key} missing AUTH_METHOD=oidc show_when" diff --git a/tests/config/test_search_mode_settings.py b/tests/config/test_search_mode_settings.py index 4e76ca1..45c4862 100644 --- a/tests/config/test_search_mode_settings.py +++ b/tests/config/test_search_mode_settings.py @@ -4,15 +4,10 @@ from shelfmark.config.settings import search_mode_settings def test_search_mode_settings_include_release_source_links_toggle(): - fields = { - field.key: field - for field in search_mode_settings() - if hasattr(field, "key") - } + fields = {field.key: field for field in search_mode_settings() if hasattr(field, "key")} field = fields["SHOW_RELEASE_SOURCE_LINKS"] assert field.label == "Show Release Source Links" assert field.default is True assert field.user_overridable is False - diff --git a/tests/config/test_security.py b/tests/config/test_security.py index a273532..1b2be24 100644 --- a/tests/config/test_security.py +++ b/tests/config/test_security.py @@ -34,7 +34,9 @@ def mock_logger(): class TestSecurityMigration: """Tests for migrating legacy security settings.""" - def test_migrate_use_cwa_auth_true_syncs_legacy_admin(self, temp_config_dir, mock_logger, monkeypatch): + def test_migrate_use_cwa_auth_true_syncs_legacy_admin( + self, temp_config_dir, mock_logger, monkeypatch + ): """USE_CWA_AUTH=True migrates to cwa and keeps legacy creds synced to users DB.""" config_root = temp_config_dir.parent monkeypatch.setenv("CONFIG_DIR", str(config_root)) @@ -48,7 +50,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(legacy_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -69,7 +74,9 @@ class TestSecurityMigration: assert user["auth_source"] == "builtin" assert user["password_hash"] == "hashed_password" - def test_migrate_use_cwa_auth_false_with_credentials(self, temp_config_dir, mock_logger, monkeypatch): + def test_migrate_use_cwa_auth_false_with_credentials( + self, temp_config_dir, mock_logger, monkeypatch + ): """USE_CWA_AUTH=False with creds migrates to builtin and syncs users DB.""" config_root = temp_config_dir.parent monkeypatch.setenv("CONFIG_DIR", str(config_root)) @@ -83,7 +90,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(legacy_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -109,7 +119,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(legacy_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -137,9 +150,14 @@ class TestSecurityMigration: return {} with patch("shelfmark.config.security.load_config_file", side_effect=_load_config): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): - with patch("shelfmark.core.settings_registry.save_config_file") as mock_save_config: + with patch( + "shelfmark.core.settings_registry.save_config_file" + ) as mock_save_config: with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -166,9 +184,14 @@ class TestSecurityMigration: return {} with patch("shelfmark.config.security.load_config_file", side_effect=_load_config): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): - with patch("shelfmark.core.settings_registry.save_config_file") as mock_save_config: + with patch( + "shelfmark.core.settings_registry.save_config_file" + ) as mock_save_config: with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -188,7 +211,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(legacy_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -214,7 +240,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(legacy_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -240,7 +269,9 @@ class TestSecurityMigration: _migrate_security_settings() - mock_logger.debug.assert_any_call("No existing security config file found - nothing to migrate") + mock_logger.debug.assert_any_call( + "No existing security config file found - nothing to migrate" + ) def test_migrate_no_changes_needed(self, temp_config_dir, mock_logger): """No-op migration should not rewrite config.""" @@ -253,7 +284,10 @@ class TestSecurityMigration: config_file.write_text(json.dumps(modern_config, indent=2)) with patch("shelfmark.config.security.load_config_file", return_value=modern_config.copy()): - with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)): + with patch( + "shelfmark.core.settings_registry._get_config_file_path", + return_value=str(config_file), + ): with patch("shelfmark.core.settings_registry._ensure_config_dir"): with patch("shelfmark.config.security.logger", mock_logger): from shelfmark.config.security import _migrate_security_settings @@ -271,6 +305,7 @@ class TestSecuritySettings: """CWA remains selectable but warns when the DB is unavailable.""" with patch("shelfmark.config.env.CWA_DB_PATH", None): import importlib + import shelfmark.config.security importlib.reload(shelfmark.config.security) @@ -296,6 +331,7 @@ class TestSecuritySettings: with patch("shelfmark.config.env.CWA_DB_PATH", mock_path): import importlib + import shelfmark.config.security importlib.reload(shelfmark.config.security) @@ -345,7 +381,9 @@ class TestSecuritySettings: fields = security_settings() auth_field = next((f for f in fields if f.key == "AUTH_METHOD"), None) - builtin_option = next((opt for opt in auth_field.options if opt["value"] == "builtin"), None) + builtin_option = next( + (opt for opt in auth_field.options if opt["value"] == "builtin"), None + ) assert builtin_option is not None assert builtin_option["label"] == "Local" diff --git a/tests/config/test_users_settings.py b/tests/config/test_users_settings.py index 7986289..8b77b9a 100644 --- a/tests/config/test_users_settings.py +++ b/tests/config/test_users_settings.py @@ -1,7 +1,7 @@ """Tests for users/request settings registration.""" -from shelfmark.config import users_settings as users_settings_module import shelfmark.config.users_settings # noqa: F401 +from shelfmark.config import users_settings as users_settings_module from shelfmark.core import settings_registry @@ -225,19 +225,38 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch): content_type_options = content_type_column["options"] assert content_type_column["filterByField"] == "source" - assert {"value": "ebook", "label": "Ebook", "childOf": "direct_download"} in content_type_options + assert { + "value": "ebook", + "label": "Ebook", + "childOf": "direct_download", + } in content_type_options assert {"value": "ebook", "label": "Ebook", "childOf": "prowlarr"} in content_type_options - assert {"value": "audiobook", "label": "Audiobook", "childOf": "prowlarr"} in content_type_options + assert { + "value": "audiobook", + "label": "Audiobook", + "childOf": "prowlarr", + } in content_type_options assert {"value": "ebook", "label": "Ebook", "childOf": "irc"} in content_type_options assert {"value": "audiobook", "label": "Audiobook", "childOf": "irc"} in content_type_options - assert {"value": "*", "label": "Any Type (*)", "childOf": "prowlarr"} not in content_type_options - assert {"value": "*", "label": "Any Type (*)", "childOf": "direct_download"} not in content_type_options + assert { + "value": "*", + "label": "Any Type (*)", + "childOf": "prowlarr", + } not in content_type_options + assert { + "value": "*", + "label": "Any Type (*)", + "childOf": "direct_download", + } not in content_type_options mode_options = columns[2]["options"] # This test verifies dynamic source/content-type option wiring; keep mode-copy checks non-brittle. assert mode_options[0]["value"] == "download" assert mode_options[0]["label"] == "Download" - assert isinstance(mode_options[0].get("description"), str) and mode_options[0]["description"].strip() + assert ( + isinstance(mode_options[0].get("description"), str) + and mode_options[0]["description"].strip() + ) assert {opt["value"] for opt in mode_options} == {"download", "request_release", "blocked"} @@ -393,7 +412,9 @@ def test_on_save_users_rejects_invalid_default_release_source_override(monkeypat result = users_settings_module._on_save_users({"DEFAULT_RELEASE_SOURCE": "unknown-source"}) assert result["error"] is True - assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"] + assert ( + "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"] + ) def test_on_save_users_rejects_audiobook_only_source_for_book_default(monkeypatch): @@ -418,7 +439,9 @@ def test_on_save_users_rejects_audiobook_only_source_for_book_default(monkeypatc result = users_settings_module._on_save_users({"DEFAULT_RELEASE_SOURCE": "audiobookbay"}) assert result["error"] is True - assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"] + assert ( + "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"] + ) def test_on_save_users_rejects_book_only_source_for_audiobook_default(monkeypatch): @@ -445,4 +468,7 @@ def test_on_save_users_rejects_book_only_source_for_audiobook_default(monkeypatc ) assert result["error"] is True - assert "DEFAULT_RELEASE_SOURCE_AUDIOBOOK must be a valid release source name or empty" in result["message"] + assert ( + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK must be a valid release source name or empty" + in result["message"] + ) diff --git a/tests/core/test_activity_routes_api.py b/tests/core/test_activity_routes_api.py index 41ea4bd..7b64b1e 100644 --- a/tests/core/test_activity_routes_api.py +++ b/tests/core/test_activity_routes_api.py @@ -122,7 +122,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 @@ -161,7 +163,10 @@ class TestActivityRoutes: assert dismiss_response.json["status"] == "dismissed" assert snapshot_response.status_code == 200 - assert {"item_type": "download", "item_key": "download:test-task"} in snapshot_response.json["dismissed"] + assert { + "item_type": "download", + "item_key": "download:test-task", + } in snapshot_response.json["dismissed"] assert history_response.status_code == 200 assert len(history_response.json) == 1 @@ -177,7 +182,9 @@ class TestActivityRoutes: assert history_after_clear.json == [] assert main_module.download_history_service.get_by_task_id("test-task") is not None - def test_dismiss_preserves_terminal_snapshot_without_live_queue_merge(self, main_module, client): + def test_dismiss_preserves_terminal_snapshot_without_live_queue_merge( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -207,7 +214,9 @@ class TestActivityRoutes: assert snapshot_download["author"] == "Recorded Author" assert snapshot_download["status_message"] is None - def test_clear_history_hides_dismissed_requests_without_deleting_them(self, main_module, client): + def test_clear_history_hides_dismissed_requests_without_deleting_them( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -234,7 +243,9 @@ class TestActivityRoutes: history_before_clear = client.get("/api/activity/history?limit=10&offset=0") clear_history_response = client.delete("/api/activity/history") history_after_clear = client.get("/api/activity/history?limit=10&offset=0") - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): snapshot_after_clear = client.get("/api/activity/snapshot") assert dismiss_response.status_code == 200 @@ -250,7 +261,9 @@ class TestActivityRoutes: assert snapshot_after_clear.status_code == 200 assert all(row["id"] != request_row["id"] for row in snapshot_after_clear.json["requests"]) - assert {"item_type": "request", "item_key": request_key} in snapshot_after_clear.json["dismissed"] + assert {"item_type": "request", "item_key": request_key} in snapshot_after_clear.json[ + "dismissed" + ] assert main_module.user_db.get_request(request_row["id"]) is not None def test_admin_snapshot_includes_admin_viewer_dismissals(self, main_module, client): @@ -270,7 +283,9 @@ class TestActivityRoutes: "/api/activity/dismiss", json={"item_type": "download", "item_key": "download:admin-visible-task"}, ) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): snapshot_response = client.get("/api/activity/snapshot") assert dismiss_response.status_code == 200 @@ -305,7 +320,9 @@ class TestActivityRoutes: assert response.data == file_bytes assert "attachment" in response.headers.get("Content-Disposition", "").lower() - def test_dismiss_legacy_fulfilled_request_creates_minimal_history_snapshot(self, main_module, client): + def test_dismiss_legacy_fulfilled_request_creates_minimal_history_snapshot( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -341,7 +358,9 @@ class TestActivityRoutes: assert history_entry["final_status"] == "complete" assert history_entry["snapshot"]["kind"] == "request" assert history_entry["snapshot"]["request"]["id"] == request_row["id"] - assert history_entry["snapshot"]["request"]["book_data"]["title"] == "Legacy Fulfilled Request" + assert ( + history_entry["snapshot"]["request"]["book_data"]["title"] == "Legacy Fulfilled Request" + ) def test_dismiss_requires_db_identity(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -486,7 +505,10 @@ class TestActivityRoutes: with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: response = client.post( "/api/activity/dismiss", - json={"item_type": "download", "item_key": "download:admin-dismiss-room-task"}, + json={ + "item_type": "download", + "item_key": "download:admin-dismiss-room-task", + }, ) assert response.status_code == 200 @@ -496,7 +518,9 @@ class TestActivityRoutes: to="admins", ) - def test_dismiss_many_preserves_terminal_snapshots_without_live_queue_merge(self, main_module, client): + def test_dismiss_many_preserves_terminal_snapshots_without_live_queue_merge( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -536,12 +560,26 @@ class TestActivityRoutes: assert history_response.status_code == 200 rows_by_key = {row["item_key"]: row for row in history_response.json} - assert rows_by_key[f"download:{first_task_id}"]["snapshot"]["download"]["title"] == "First Title" - assert rows_by_key[f"download:{first_task_id}"]["snapshot"]["download"]["author"] == "First Author" - assert rows_by_key[f"download:{second_task_id}"]["snapshot"]["download"]["title"] == "Second Title" - assert rows_by_key[f"download:{second_task_id}"]["snapshot"]["download"]["author"] == "Second Author" + assert ( + rows_by_key[f"download:{first_task_id}"]["snapshot"]["download"]["title"] + == "First Title" + ) + assert ( + rows_by_key[f"download:{first_task_id}"]["snapshot"]["download"]["author"] + == "First Author" + ) + assert ( + rows_by_key[f"download:{second_task_id}"]["snapshot"]["download"]["title"] + == "Second Title" + ) + assert ( + rows_by_key[f"download:{second_task_id}"]["snapshot"]["download"]["author"] + == "Second Author" + ) - def test_dismiss_many_accepts_stale_active_download_as_interrupted_history(self, main_module, client): + def test_dismiss_many_accepts_stale_active_download_as_interrupted_history( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -563,7 +601,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): dismiss_many_response = client.post( "/api/activity/dismiss-many", json={"items": [{"item_type": "download", "item_key": f"download:{task_id}"}]}, @@ -579,7 +619,9 @@ class TestActivityRoutes: assert history_response.json[0]["final_status"] == "error" assert history_response.json[0]["snapshot"]["download"]["status_message"] == "Interrupted" - def test_dismiss_many_preserves_retry_for_stale_active_requested_download_history(self, main_module, client): + def test_dismiss_many_preserves_retry_for_stale_active_requested_download_history( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -615,7 +657,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): dismiss_many_response = client.post( "/api/activity/dismiss-many", json={"items": [{"item_type": "download", "item_key": f"download:{task_id}"}]}, @@ -630,7 +674,9 @@ class TestActivityRoutes: assert history_response.json[0]["snapshot"]["download"]["status_message"] == "Interrupted" assert history_response.json[0]["snapshot"]["download"]["retry_available"] is True - def test_dismiss_many_returns_404_without_partial_dismiss_when_any_item_is_missing(self, main_module, client): + def test_dismiss_many_returns_404_without_partial_dismiss_when_any_item_is_missing( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -691,7 +737,9 @@ class TestActivityRoutes: "/api/activity/dismiss-many", json={"items": [{"item_type": "download", "item_key": item_key}]}, ) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): snapshot_one = client_one.get("/api/activity/snapshot") snapshot_two = client_two.get("/api/activity/snapshot") history_one = client_one.get("/api/activity/history?limit=10&offset=0") @@ -711,7 +759,9 @@ class TestActivityRoutes: def test_no_auth_dismiss_many_ignores_stale_session_db_identity(self, main_module, client): stale_db_user_id = 999999999 - _set_session(client, user_id="stale-session-user", db_user_id=stale_db_user_id, is_admin=False) + _set_session( + client, user_id="stale-session-user", db_user_id=stale_db_user_id, is_admin=False + ) task_id = f"no-auth-stale-{uuid.uuid4().hex[:8]}" item_key = f"download:{task_id}" @@ -764,14 +814,20 @@ class TestActivityRoutes: "/api/activity/dismiss-many", json={"items": [{"item_type": "download", "item_key": item_key}]}, ) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): snapshot_response = other_client.get("/api/activity/snapshot") assert dismiss_response.status_code == 200 assert snapshot_response.status_code == 200 - assert {"item_type": "download", "item_key": item_key} in snapshot_response.json["dismissed"] + assert {"item_type": "download", "item_key": item_key} in snapshot_response.json[ + "dismissed" + ] - def test_dismiss_many_with_stale_db_identity_returns_identity_unavailable(self, main_module, client): + def test_dismiss_many_with_stale_db_identity_returns_identity_unavailable( + self, main_module, client + ): _set_session(client, user_id="stale-session-user", db_user_id=999999999, is_admin=False) with patch.object(main_module, "get_auth_mode", return_value="builtin"): @@ -830,10 +886,14 @@ class TestActivityRoutes: assert f"user={admin['username']}" in log_message assert "is_admin=True" in log_message - def test_dismiss_many_logs_actor_and_row_context_for_forbidden_download(self, main_module, client): + def test_dismiss_many_logs_actor_and_row_context_for_forbidden_download( + self, main_module, client + ): owner = _create_user(main_module, prefix="owner") intruder = _create_user(main_module, prefix="intruder") - _set_session(client, user_id=intruder["username"], db_user_id=intruder["id"], is_admin=False) + _set_session( + client, user_id=intruder["username"], db_user_id=intruder["id"], is_admin=False + ) _record_terminal_download( main_module, @@ -848,7 +908,14 @@ class TestActivityRoutes: with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning: response = client.post( "/api/activity/dismiss-many", - json={"items": [{"item_type": "download", "item_key": "download:forbidden-download-task"}]}, + json={ + "items": [ + { + "item_type": "download", + "item_key": "download:forbidden-download-task", + } + ] + }, ) assert response.status_code == 403 @@ -864,7 +931,9 @@ class TestActivityRoutes: assert "final_status=complete" in log_message assert "request_id=321" in log_message - def test_snapshot_backfills_undismissed_terminal_download_from_download_history(self, main_module, client): + def test_snapshot_backfills_undismissed_terminal_download_from_download_history( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -879,7 +948,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 @@ -904,12 +975,17 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 assert "cross-user-expired-task" in response.json["status"]["complete"] - assert response.json["status"]["complete"]["cross-user-expired-task"]["id"] == "cross-user-expired-task" + assert ( + response.json["status"]["complete"]["cross-user-expired-task"]["id"] + == "cross-user-expired-task" + ) def test_snapshot_shows_stale_active_download_as_interrupted_error(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -933,14 +1009,20 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 assert "stale-active-task" in response.json["status"]["error"] - assert response.json["status"]["error"]["stale-active-task"]["status_message"] == "Interrupted" + assert ( + response.json["status"]["error"]["stale-active-task"]["status_message"] == "Interrupted" + ) - def test_snapshot_preserves_retry_for_stale_active_requested_download(self, main_module, client): + def test_snapshot_preserves_retry_for_stale_active_requested_download( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -976,14 +1058,18 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 assert response.json["status"]["error"][task_id]["status_message"] == "Interrupted" assert response.json["status"]["error"][task_id]["retry_available"] is True - def test_snapshot_includes_retry_available_for_live_terminal_downloads(self, main_module, client): + def test_snapshot_includes_retry_available_for_live_terminal_downloads( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -1005,13 +1091,19 @@ class TestActivityRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=queue_status_payload): + with patch.object( + main_module.backend, "queue_status", return_value=queue_status_payload + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 - assert response.json["status"]["error"]["retryable-terminal-task"]["retry_available"] is True + assert ( + response.json["status"]["error"]["retryable-terminal-task"]["retry_available"] is True + ) - def test_snapshot_reopens_request_when_error_retry_is_no_longer_available(self, main_module, client): + def test_snapshot_reopens_request_when_error_retry_is_no_longer_available( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -1071,7 +1163,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): response = client.get("/api/activity/snapshot") assert response.status_code == 200 @@ -1083,7 +1177,9 @@ class TestActivityRoutes: for row in response.json["requests"] ) - def test_snapshot_active_download_with_queue_entry_shows_in_correct_bucket(self, main_module, client): + def test_snapshot_active_download_with_queue_entry_shows_in_correct_bucket( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -1125,7 +1221,9 @@ class TestActivityRoutes: assert "active-downloading-task" in response.json["status"]["downloading"] assert response.json["status"]["downloading"]["active-downloading-task"]["progress"] == 0.5 - def test_snapshot_ignores_queue_only_active_download_without_history_row(self, main_module, client): + def test_snapshot_ignores_queue_only_active_download_without_history_row( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -1207,7 +1305,9 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - _set_session(client, user_id=user_one["username"], db_user_id=user_one["id"], is_admin=False) + _set_session( + client, user_id=user_one["username"], db_user_id=user_one["id"], is_admin=False + ) dismiss_response = client.post( "/api/activity/dismiss", json={"item_type": "download", "item_key": "download:shared-task"}, @@ -1216,12 +1316,20 @@ class TestActivityRoutes: snapshot_one = client.get("/api/activity/snapshot") assert snapshot_one.status_code == 200 - assert {"item_type": "download", "item_key": "download:shared-task"} in snapshot_one.json["dismissed"] + assert { + "item_type": "download", + "item_key": "download:shared-task", + } in snapshot_one.json["dismissed"] - _set_session(client, user_id=user_two["username"], db_user_id=user_two["id"], is_admin=False) + _set_session( + client, user_id=user_two["username"], db_user_id=user_two["id"], is_admin=False + ) snapshot_two = client.get("/api/activity/snapshot") assert snapshot_two.status_code == 200 - assert {"item_type": "download", "item_key": "download:shared-task"} not in snapshot_two.json["dismissed"] + assert { + "item_type": "download", + "item_key": "download:shared-task", + } not in snapshot_two.json["dismissed"] def test_admin_dismiss_and_clear_do_not_affect_owner_view(self, main_module, client): admin = _create_user(main_module, prefix="admin", role="admin") @@ -1249,7 +1357,9 @@ class TestActivityRoutes: assert any(row["item_key"] == f"download:{task_id}" for row in admin_history.json) _set_session(client, user_id=owner["username"], db_user_id=owner["id"], is_admin=False) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): owner_snapshot_after_admin_dismiss = client.get("/api/activity/snapshot") assert owner_snapshot_after_admin_dismiss.status_code == 200 assert task_id in owner_snapshot_after_admin_dismiss.json["status"]["complete"] @@ -1264,7 +1374,9 @@ class TestActivityRoutes: assert clear_response.json["cleared_count"] >= 1 _set_session(client, user_id=owner["username"], db_user_id=owner["id"], is_admin=False) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): owner_snapshot_after_admin_clear = client.get("/api/activity/snapshot") owner_history = client.get("/api/activity/history?limit=10&offset=0") @@ -1296,23 +1408,34 @@ class TestActivityRoutes: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - _set_session(client, user_id=admin_one["username"], db_user_id=admin_one["id"], is_admin=True) + _set_session( + client, user_id=admin_one["username"], db_user_id=admin_one["id"], is_admin=True + ) dismiss_response = client.post( "/api/activity/dismiss", json={"item_type": "request", "item_key": f"request:{request_row['id']}"}, ) assert dismiss_response.status_code == 200 - _set_session(client, user_id=admin_two["username"], db_user_id=admin_two["id"], is_admin=True) - with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + _set_session( + client, user_id=admin_two["username"], db_user_id=admin_two["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_status", return_value=_sample_status_payload() + ): snapshot_response = client.get("/api/activity/snapshot") history_response = client.get("/api/activity/history?limit=50&offset=0") assert snapshot_response.status_code == 200 - assert {"item_type": "request", "item_key": f"request:{request_row['id']}"} in snapshot_response.json["dismissed"] + assert { + "item_type": "request", + "item_key": f"request:{request_row['id']}", + } in snapshot_response.json["dismissed"] assert history_response.status_code == 200 - assert any(row["item_key"] == f"request:{request_row['id']}" for row in history_response.json) + assert any( + row["item_key"] == f"request:{request_row['id']}" for row in history_response.json + ) def test_admin_request_history_includes_requester_username(self, main_module, client): admin = _create_user(main_module, prefix="admin", role="admin") @@ -1341,7 +1464,11 @@ class TestActivityRoutes: assert dismiss_response.status_code == 200 assert history_response.status_code == 200 - matching_rows = [row for row in history_response.json if row["item_key"] == f"request:{request_row['id']}"] + matching_rows = [ + row + for row in history_response.json + if row["item_key"] == f"request:{request_row['id']}" + ] assert len(matching_rows) == 1 assert matching_rows[0]["snapshot"]["request"]["username"] == owner["username"] @@ -1415,7 +1542,9 @@ class TestActivityRoutes: to=f"user_{user['id']}", ) - def test_clear_history_emits_activity_update_only_to_acting_user_room(self, main_module, client): + def test_clear_history_emits_activity_update_only_to_acting_user_room( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) _record_terminal_download( diff --git a/tests/core/test_activity_terminal_snapshots.py b/tests/core/test_activity_terminal_snapshots.py index f43cfad..a4922c1 100644 --- a/tests/core/test_activity_terminal_snapshots.py +++ b/tests/core/test_activity_terminal_snapshots.py @@ -265,13 +265,16 @@ class TestTerminalSnapshotCapture: assert main_module.backend.book_queue.add(task) is True try: - main_module.backend.book_queue.update_status_message(task_id, "Destination not writable") + main_module.backend.book_queue.update_status_message( + task_id, "Destination not writable" + ) with patch.object(main_module, "reopen_failed_request") as mock_reopen: main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR) mock_reopen.assert_not_called() persisted_request = next( - row for row in main_module.user_db.list_requests(user_id=user["id"]) + row + for row in main_module.user_db.list_requests(user_id=user["id"]) if row["id"] == request_row["id"] ) assert persisted_request["status"] == "fulfilled" diff --git a/tests/core/test_activity_view_state_service.py b/tests/core/test_activity_view_state_service.py index 6cac892..17d7ef5 100644 --- a/tests/core/test_activity_view_state_service.py +++ b/tests/core/test_activity_view_state_service.py @@ -45,16 +45,24 @@ class TestActivityViewStateService: user_hidden = activity_view_state_service.list_hidden(viewer_scope="user:1") assert {row["item_key"] for row in user_hidden} == {"download:first-task", "request:12"} - user_history = activity_view_state_service.list_history(viewer_scope="user:1", limit=10, offset=0) + user_history = activity_view_state_service.list_history( + viewer_scope="user:1", limit=10, offset=0 + ) assert [row["item_key"] for row in user_history] == ["request:12", "download:first-task"] assert all(isinstance(row["dismissed_at"], str) for row in user_history) cleared_count = activity_view_state_service.clear_history(viewer_scope="user:1") assert cleared_count == 2 - assert activity_view_state_service.list_history(viewer_scope="user:1", limit=10, offset=0) == [] + assert ( + activity_view_state_service.list_history(viewer_scope="user:1", limit=10, offset=0) + == [] + ) user_hidden_after_clear = activity_view_state_service.list_hidden(viewer_scope="user:1") - assert {row["item_key"] for row in user_hidden_after_clear} == {"download:first-task", "request:12"} + assert {row["item_key"] for row in user_hidden_after_clear} == { + "download:first-task", + "request:12", + } admin_history = activity_view_state_service.list_history( viewer_scope="admin:shared", @@ -106,8 +114,7 @@ class TestActivityViewStateService: def test_list_hidden_returns_all_rows_by_default(self, activity_view_state_service): items = [ - {"item_type": "request", "item_key": f"request:{index}"} - for index in range(1, 5002) + {"item_type": "request", "item_key": f"request:{index}"} for index in range(1, 5002) ] activity_view_state_service.dismiss_many( viewer_scope="user:1", diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index 7e854da..6a72546 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -7,7 +7,6 @@ Tests CRUD endpoints for managing users from the admin panel. import os import sqlite3 import tempfile - from unittest.mock import patch import pytest @@ -101,10 +100,12 @@ class TestAdminUsersListEndpoint: def test_list_users_excludes_password_hash(self, admin_client, user_db): user_db.create_user(username="alice", password_hash="secret_hash") + user_db.create_user(username="bob", password_hash="another_secret_hash") resp = admin_client.get("/api/admin/users") users = resp.json - assert "password_hash" not in users[0] + assert users + assert all("password_hash" not in user for user in users) def test_list_users_includes_auth_source_and_is_active(self, admin_client, user_db): user_db.create_user(username="local_user", auth_source="builtin") @@ -221,7 +222,9 @@ class TestAdminUserCreateEndpoint: user = user_db.get_user(username="alice") assert user["password_hash"] is not None assert user["password_hash"] != "pass1234" - assert user["password_hash"].startswith("scrypt:") or user["password_hash"].startswith("pbkdf2:") + assert user["password_hash"].startswith("scrypt:") or user["password_hash"].startswith( + "pbkdf2:" + ) def test_create_user_requires_admin(self, regular_client): resp = regular_client.post( @@ -357,6 +360,18 @@ class TestAdminUserCreateEndpoint: assert resp.status_code == 201 assert resp.json["username"] == "alice" + def test_create_user_allowed_without_session_in_no_auth(self, no_session_client, user_db): + resp = no_session_client.post( + "/api/admin/users", + json={"username": "alice", "password": "pass1234"}, + ) + + assert resp.status_code == 201 + assert resp.json["username"] == "alice" + created = user_db.get_user(username="alice") + assert created is not None + assert created["role"] == "admin" + # --------------------------------------------------------------------------- # GET /api/admin/users/ @@ -520,13 +535,16 @@ class TestAdminUserUpdateEndpoint: resp = admin_client.put( f"/api/admin/users/{user['id']}", - json={"settings": {"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "not-a-valid-url"}]}}, + json={ + "settings": { + "USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "not-a-valid-url"}] + } + }, ) assert resp.status_code == 400 assert resp.json["error"] == "Invalid settings payload" assert any( - "Invalid value for USER_NOTIFICATION_ROUTES" in msg - for msg in resp.json["details"] + "Invalid value for USER_NOTIFICATION_ROUTES" in msg for msg in resp.json["details"] ) def test_update_user_settings_accepts_valid_request_policy_rule(self, admin_client, user_db): @@ -557,7 +575,9 @@ class TestAdminUserUpdateEndpoint: } ] - def test_update_user_settings_rejects_invalid_source_content_type_pair(self, admin_client, user_db): + def test_update_user_settings_rejects_invalid_source_content_type_pair( + self, admin_client, user_db + ): user = user_db.create_user(username="alice") resp = admin_client.put( @@ -578,8 +598,7 @@ class TestAdminUserUpdateEndpoint: assert resp.status_code == 400 assert resp.json["error"] == "Invalid settings payload" assert any( - "does not support content_type 'audiobook'" in msg - for msg in resp.json["details"] + "does not support content_type 'audiobook'" in msg for msg in resp.json["details"] ) def test_update_settings_merges(self, admin_client, user_db): @@ -632,9 +651,14 @@ class TestAdminUserUpdateEndpoint: def test_update_user_settings_null_policy_rules_accepted(self, admin_client, user_db): user = user_db.create_user(username="alice") - user_db.set_user_settings(user["id"], { - "REQUEST_POLICY_RULES": [{"source": "prowlarr", "content_type": "audiobook", "mode": "request_release"}], - }) + user_db.set_user_settings( + user["id"], + { + "REQUEST_POLICY_RULES": [ + {"source": "prowlarr", "content_type": "audiobook", "mode": "request_release"} + ], + }, + ) resp = admin_client.put( f"/api/admin/users/{user['id']}", @@ -646,19 +670,24 @@ class TestAdminUserUpdateEndpoint: def test_update_user_settings_mixed_null_and_values(self, admin_client, user_db): user = user_db.create_user(username="alice") - user_db.set_user_settings(user["id"], { - "DESTINATION": "/books/alice", - "REQUEST_POLICY_DEFAULT_EBOOK": "request_book", - }) + user_db.set_user_settings( + user["id"], + { + "DESTINATION": "/books/alice", + "REQUEST_POLICY_DEFAULT_EBOOK": "request_book", + }, + ) resp = admin_client.put( f"/api/admin/users/{user['id']}", - json={"settings": { - "DESTINATION": None, - "BOOKLORE_LIBRARY_ID": "5", - "REQUEST_POLICY_DEFAULT_EBOOK": None, - "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download", - }}, + json={ + "settings": { + "DESTINATION": None, + "BOOKLORE_LIBRARY_ID": "5", + "REQUEST_POLICY_DEFAULT_EBOOK": None, + "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download", + } + }, ) assert resp.status_code == 200 settings = user_db.get_user_settings(user["id"]) @@ -687,7 +716,9 @@ class TestAdminUserUpdateEndpoint: ) assert resp.status_code == 400 assert resp.json["error"] == "Invalid settings payload" - assert any("Setting not user-overridable: FILE_ORGANIZATION" in msg for msg in resp.json["details"]) + assert any( + "Setting not user-overridable: FILE_ORGANIZATION" in msg for msg in resp.json["details"] + ) def test_update_user_settings_rejects_lowercase_key(self, admin_client, user_db): user = user_db.create_user(username="alice") @@ -704,7 +735,9 @@ class TestAdminUserUpdateEndpoint: user = user_db.create_user(username="alice") with ( - patch("shelfmark.core.admin_routes.app_config.refresh", side_effect=RuntimeError("boom")), + patch( + "shelfmark.core.admin_routes.app_config.refresh", side_effect=RuntimeError("boom") + ), patch("shelfmark.core.admin_routes.logger.warning") as mock_warning, ): resp = admin_client.put( @@ -816,7 +849,9 @@ class TestAdminUserPasswordUpdate: updated = user_db.get_user(user_id=user["id"]) assert updated["password_hash"] != "old_hash" - assert updated["password_hash"].startswith("scrypt:") or updated["password_hash"].startswith("pbkdf2:") + assert updated["password_hash"].startswith("scrypt:") or updated[ + "password_hash" + ].startswith("pbkdf2:") def test_update_password_too_short(self, admin_client, user_db): """Password shorter than 4 characters should be rejected.""" @@ -964,7 +999,8 @@ class TestAdminSyncCwaUsersEndpoint: assert bob_original["email"] == "old@example.com" bob_cwa = next( - user for user in user_db.list_users() + user + for user in user_db.list_users() if user.get("auth_source") == "cwa" and user.get("email") == "bob@example.com" ) assert bob_cwa["username"].startswith("bob__cwa") @@ -1001,6 +1037,7 @@ class TestAdminDownloadDefaults: """Create a temporary downloads config file.""" import json from pathlib import Path + from shelfmark.core.config import config as app_config config_dir = str(tmp_path) @@ -1122,6 +1159,7 @@ class TestAdminDeliveryPreferences: (plugins_dir / "downloads.json").write_text(json.dumps(downloads_config)) from shelfmark.core.config import config as app_config + app_config.refresh(force=True) def test_returns_curated_fields_and_effective_values(self, admin_client, user_db): @@ -1206,6 +1244,7 @@ class TestAdminSearchPreferences: (plugins_dir / "search_mode.json").write_text(json.dumps(search_mode_config)) from shelfmark.core.config import config as app_config + app_config.refresh(force=True) def test_returns_curated_fields_and_effective_values(self, admin_client, user_db): @@ -1246,7 +1285,10 @@ class TestAdminSearchPreferences: assert data["effective"]["SEARCH_MODE"]["source"] == "user_override" assert data["effective"]["SEARCH_MODE"]["value"] == "universal" assert data["effective"]["METADATA_PROVIDER"]["source"] == "user_override" - assert data["effective"]["METADATA_PROVIDER_AUDIOBOOK"]["source"] in {"global_config", "default"} + assert data["effective"]["METADATA_PROVIDER_AUDIOBOOK"]["source"] in { + "global_config", + "default", + } assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["source"] == "user_override" assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["value"] == "prowlarr" assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["source"] == "user_override" @@ -1293,6 +1335,7 @@ class TestAdminNotificationPreferences: (plugins_dir / "notifications.json").write_text(json.dumps(notifications_config)) from shelfmark.core.config import config as app_config + app_config.refresh(force=True) def test_returns_curated_fields_and_effective_values(self, admin_client, user_db): @@ -1366,6 +1409,7 @@ class TestAdminNotificationPreferencesTestAction: (plugins_dir / "notifications.json").write_text(json.dumps(notifications_config)) from shelfmark.core.config import config as app_config + app_config.refresh(force=True) def test_requires_admin(self, regular_client, user_db): @@ -1400,9 +1444,7 @@ class TestAdminNotificationPreferencesTestAction: assert resp.status_code == 200 assert resp.json["success"] is True - mock_send.assert_called_once_with( - ["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"] - ) + mock_send.assert_called_once_with(["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"]) def test_uses_effective_routes_when_payload_missing(self, admin_client, user_db): user = user_db.create_user(username="alice") @@ -1525,6 +1567,7 @@ class TestAdminEffectiveSettings: # Ensure config singleton sees the current test env/config dir. from shelfmark.core.config import config as app_config + app_config.refresh(force=True) def test_returns_effective_values_with_sources(self, admin_client, user_db): @@ -1622,7 +1665,9 @@ class TestAdminUserDeleteEndpoint: assert resp.status_code == 200 assert resp.json["success"] is True - def test_delete_active_oidc_user_allowed_when_auto_provision_enabled(self, admin_client, user_db): + def test_delete_active_oidc_user_allowed_when_auto_provision_enabled( + self, admin_client, user_db + ): user = user_db.create_user( username="oidcuser", oidc_subject="sub-123", @@ -1649,6 +1694,24 @@ class TestAdminUserDeleteEndpoint: assert resp.json["success"] is True assert user_db.get_user(user_id=user["id"]) is None + def test_delete_own_account_rejected(self, admin_client, user_db): + user = user_db.create_user( + username="onlyadmin", + password_hash="hashed_pw", + role="admin", + ) + + with admin_client.session_transaction() as sess: + sess["user_id"] = user["username"] + sess["db_user_id"] = user["id"] + sess["is_admin"] = True + + resp = admin_client.delete(f"/api/admin/users/{user['id']}") + + assert resp.status_code == 400 + assert resp.json["error"] == "Cannot delete your own account" + assert user_db.get_user(user_id=user["id"]) is not None + # --------------------------------------------------------------------------- # OIDC lockout prevention (security on_save handler) @@ -1669,6 +1732,7 @@ class TestOIDCLockoutPrevention: def _call_on_save(self, values): from shelfmark.config.security import _on_save_security + return _on_save_security(values) def test_oidc_blocked_without_local_admin(self): diff --git a/tests/core/test_auth_api.py b/tests/core/test_auth_api.py index d91fc73..4ee0056 100644 --- a/tests/core/test_auth_api.py +++ b/tests/core/test_auth_api.py @@ -7,6 +7,7 @@ from datetime import datetime from unittest.mock import patch import pytest +from werkzeug.security import generate_password_hash @pytest.fixture(scope="module") @@ -28,12 +29,174 @@ def client(main_module): main_module.failed_login_attempts.clear() +@pytest.fixture +def temp_user_db(tmp_path): + from shelfmark.core.user_db import UserDB + + db = UserDB(str(tmp_path / "users.db")) + db.initialize() + return db + + +class TestLoginSemantics: + def test_login_rejects_missing_payload(self, main_module, client): + response = client.post("/api/auth/login") + + assert response.status_code == 400 + assert response.get_json()["error"] == "No data provided" + + def test_login_in_none_mode_sets_session_without_db_user(self, main_module, client): + with patch.object(main_module, "get_auth_mode", return_value="none"): + response = client.post( + "/api/auth/login", + json={"username": "guest", "password": "ignored", "remember_me": True}, + ) + + assert response.status_code == 200 + assert response.get_json() == {"success": True} + with client.session_transaction() as sess: + assert sess["user_id"] == "guest" + assert "db_user_id" not in sess + assert sess.permanent is True + + def test_login_builtin_success_sets_session_and_admin_flag( + self, main_module, client, temp_user_db, monkeypatch + ): + monkeypatch.setattr(main_module, "user_db", temp_user_db) + user = temp_user_db.create_user( + username="alice", + password_hash=generate_password_hash("secret"), + display_name="Alice Example", + role="admin", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + response = client.post( + "/api/auth/login", + json={"username": "alice", "password": "secret", "remember_me": False}, + ) + + assert response.status_code == 200 + assert response.get_json() == {"success": True} + with client.session_transaction() as sess: + assert sess["user_id"] == "alice" + assert sess["db_user_id"] == user["id"] + assert sess["is_admin"] is True + assert sess.permanent is False + assert "alice" not in main_module.failed_login_attempts + + def test_login_builtin_rejects_wrong_password_and_tracks_failure( + self, main_module, client, temp_user_db, monkeypatch + ): + monkeypatch.setattr(main_module, "user_db", temp_user_db) + temp_user_db.create_user( + username="alice", + password_hash=generate_password_hash("secret"), + role="user", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + response = client.post( + "/api/auth/login", + json={"username": "alice", "password": "wrong", "remember_me": False}, + ) + + assert response.status_code == 401 + assert response.get_json()["error"] == "Invalid username or password." + assert main_module.failed_login_attempts["alice"]["count"] == 1 + + def test_login_rejects_proxy_mode(self, main_module, client): + with patch.object(main_module, "get_auth_mode", return_value="proxy"): + response = client.post( + "/api/auth/login", + json={"username": "alice", "password": "secret", "remember_me": False}, + ) + + assert response.status_code == 401 + assert response.get_json()["error"] == "Proxy authentication is enabled" + + def test_login_rejects_oidc_when_local_auth_is_hidden(self, main_module, client): + with patch.object(main_module, "get_auth_mode", return_value="oidc"): + with patch.object(main_module, "HIDE_LOCAL_AUTH", True): + response = client.post( + "/api/auth/login", + json={"username": "alice", "password": "secret", "remember_me": False}, + ) + + assert response.status_code == 403 + assert response.get_json()["error"] == "Local authentication is disabled" + + def test_auth_check_none_mode_reports_full_access(self, main_module, client): + with patch.object(main_module, "get_auth_mode", return_value="none"): + response = client.get("/api/auth/check") + + assert response.status_code == 200 + assert response.get_json() == { + "authenticated": True, + "auth_required": False, + "auth_mode": "none", + "is_admin": True, + } + + def test_auth_check_includes_display_name_for_authenticated_user( + self, main_module, client, temp_user_db, monkeypatch + ): + monkeypatch.setattr(main_module, "user_db", temp_user_db) + user = temp_user_db.create_user( + username="alice", + password_hash=generate_password_hash("secret"), + display_name="Alice Example", + role="admin", + ) + + with client.session_transaction() as sess: + sess["user_id"] = "alice" + sess["db_user_id"] = user["id"] + sess["is_admin"] = True + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + response = client.get("/api/auth/check") + + assert response.status_code == 200 + body = response.get_json() + assert body["authenticated"] is True + assert body["auth_required"] is True + assert body["auth_mode"] == "builtin" + assert body["is_admin"] is True + assert body["username"] == "alice" + assert body["display_name"] == "Alice Example" + + def test_logout_proxy_includes_logout_url_and_clears_session(self, main_module, client): + with client.session_transaction() as sess: + sess["user_id"] = "alice" + sess["db_user_id"] = 1 + sess["is_admin"] = True + + with patch.object(main_module, "get_auth_mode", return_value="proxy"): + with patch.object( + main_module.app_config, + "get", + side_effect=lambda key, default=None, user_id=None: { + "PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout", + }.get(key, default), + ): + response = client.post("/api/auth/logout") + + assert response.status_code == 200 + assert response.get_json() == { + "success": True, + "logout_url": "https://auth.example.com/logout", + } + with client.session_transaction() as sess: + assert "user_id" not in sess + assert "db_user_id" not in sess + assert "is_admin" not in sess + + class TestLoginLockoutRepair: def test_is_account_locked_repairs_missing_timestamp(self, main_module): main_module.failed_login_attempts.clear() - main_module.failed_login_attempts["locked-user"] = { - "count": main_module.MAX_LOGIN_ATTEMPTS - } + main_module.failed_login_attempts["locked-user"] = {"count": main_module.MAX_LOGIN_ATTEMPTS} assert main_module.is_account_locked("locked-user") is True assert isinstance( @@ -41,9 +204,7 @@ class TestLoginLockoutRepair: ) def test_login_keeps_account_locked_when_timestamp_is_missing(self, main_module, client): - main_module.failed_login_attempts["locked-user"] = { - "count": main_module.MAX_LOGIN_ATTEMPTS - } + main_module.failed_login_attempts["locked-user"] = {"count": main_module.MAX_LOGIN_ATTEMPTS} with patch.object(main_module, "get_auth_mode", return_value="builtin"): response = client.post( diff --git a/tests/core/test_booklore_multiuser.py b/tests/core/test_booklore_multiuser.py index b027e93..2e3ad57 100644 --- a/tests/core/test_booklore_multiuser.py +++ b/tests/core/test_booklore_multiuser.py @@ -63,6 +63,7 @@ class TestBuildBookloreConfigWithOverrides: def test_auth_fields_remain_global(self, monkeypatch): """Only Booklore library/path should be resolved with user context.""" + def fake_get(key, default=None, user_id=None): if user_id == 7 and key == "BOOKLORE_LIBRARY_ID": return 5 diff --git a/tests/core/test_builtin_multiuser.py b/tests/core/test_builtin_multiuser.py index 041b82d..dd77f5d 100644 --- a/tests/core/test_builtin_multiuser.py +++ b/tests/core/test_builtin_multiuser.py @@ -32,8 +32,12 @@ class TestBuiltinMultiUserLogin: assert user["role"] == "user" def test_create_admin_and_regular_user(self, db): - db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin") - db.create_user(username="user1", password_hash=generate_password_hash("user123"), role="user") + db.create_user( + username="admin", password_hash=generate_password_hash("admin123"), role="admin" + ) + db.create_user( + username="user1", password_hash=generate_password_hash("user123"), role="user" + ) users = db.list_users() assert len(users) == 2 roles = {u["username"]: u["role"] for u in users} @@ -87,7 +91,9 @@ class TestMigrateBuiltinConfig: def test_skip_migration_if_users_exist(self, db): """Don't re-migrate if users already exist in DB.""" - db.create_user(username="existing_admin", password_hash=generate_password_hash("pw"), role="admin") + db.create_user( + username="existing_admin", password_hash=generate_password_hash("pw"), role="admin" + ) # Should have 1 user already, migration should be skipped assert len(db.list_users()) == 1 @@ -113,7 +119,9 @@ class TestBuiltinLoginLogic: } def test_login_admin(self, db): - db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin") + db.create_user( + username="admin", password_hash=generate_password_hash("admin123"), role="admin" + ) result = self._builtin_login(db, "admin", "admin123") assert result is not None assert result["is_admin"] is True @@ -126,7 +134,9 @@ class TestBuiltinLoginLogic: assert result["is_admin"] is False def test_login_wrong_password(self, db): - db.create_user(username="user1", password_hash=generate_password_hash("correct"), role="user") + db.create_user( + username="user1", password_hash=generate_password_hash("correct"), role="user" + ) result = self._builtin_login(db, "user1", "wrong") assert result is None @@ -135,6 +145,8 @@ class TestBuiltinLoginLogic: assert result is None def test_login_sets_db_user_id(self, db): - user = db.create_user(username="dave", password_hash=generate_password_hash("pw"), role="user") + user = db.create_user( + username="dave", password_hash=generate_password_hash("pw"), role="user" + ) result = self._builtin_login(db, "dave", "pw") assert result["db_user_id"] == user["id"] diff --git a/tests/core/test_config_access_guardrails.py b/tests/core/test_config_access_guardrails.py new file mode 100644 index 0000000..bf9f37a --- /dev/null +++ b/tests/core/test_config_access_guardrails.py @@ -0,0 +1,495 @@ +"""Guardrails that keep runtime config access behind the Config singleton.""" + +import ast +from dataclasses import dataclass +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PACKAGE_ROOT = _REPO_ROOT / "shelfmark" +_SETTINGS_SOURCE_ROOTS = ( + _PACKAGE_ROOT / "config", + _PACKAGE_ROOT / "metadata_providers", + _PACKAGE_ROOT / "release_sources", +) +_VALUE_FIELD_TYPES = { + "TextField", + "PasswordField", + "NumberField", + "CheckboxField", + "SelectField", + "MultiSelectField", + "TagListField", + "OrderableListField", + "TableField", +} +_BOOTSTRAP_ENV_ACCESS_ALLOWLIST = { + Path("shelfmark/config/env.py"), + Path("shelfmark/core/settings_registry.py"), +} +_BOOTSTRAP_ENV_ACCESS_KEY_ALLOWLIST = { + (Path("shelfmark/config/settings.py"), "USING_TOR"), +} +_RAW_CONFIG_READ_ALLOWLIST = { + Path("shelfmark/config/notifications_settings.py"), + Path("shelfmark/config/settings.py"), + Path("shelfmark/core/admin_settings_routes.py"), + Path("shelfmark/core/settings_registry.py"), + Path("shelfmark/core/user_settings_overrides.py"), +} + + +@dataclass(frozen=True) +class GuardrailViolation: + """A direct config-access violation found in source.""" + + path: Path + line: int + message: str + + +class SettingsFieldCollector(ast.NodeVisitor): + """Collect registered setting keys and env var names from source.""" + + def __init__(self) -> None: + self.registered_keys: set[str] = set() + self.registered_env_vars: set[str] = set() + self._constants: dict[str, str | bool] = {} + + def visit_Assign(self, node: ast.Assign) -> None: + resolved_value = self._resolve_constant(node.value) + + for target in node.targets: + for name in self._iter_assigned_names(target): + if resolved_value is None: + self._constants.pop(name, None) + else: + self._constants[name] = resolved_value + + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + if node.value is None: + return + + resolved_value = self._resolve_constant(node.value) + for name in self._iter_assigned_names(node.target): + if resolved_value is None: + self._constants.pop(name, None) + else: + self._constants[name] = resolved_value + + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + if not self._looks_like_value_field_definition(node): + self.generic_visit(node) + return + + key = self._get_keyword_string(node, "key") + if key is None: + self.generic_visit(node) + return + + self.registered_keys.add(key) + + env_supported = self._get_keyword_bool(node, "env_supported") + if env_supported is not False: + self.registered_env_vars.add(self._get_keyword_string(node, "env_var") or key) + + self.generic_visit(node) + + def _looks_like_value_field_definition(self, node: ast.Call) -> bool: + func_name = self._get_callable_name(node.func) + if func_name in _VALUE_FIELD_TYPES: + return True + + return any( + self._get_callable_name(argument) in _VALUE_FIELD_TYPES for argument in node.args + ) + + def _get_keyword_string(self, node: ast.Call, key: str) -> str | None: + for keyword in node.keywords: + if keyword.arg == key: + resolved = self._resolve_constant(keyword.value) + if isinstance(resolved, str): + return resolved + return None + + def _get_keyword_bool(self, node: ast.Call, key: str) -> bool | None: + for keyword in node.keywords: + if keyword.arg == key: + resolved = self._resolve_constant(keyword.value) + if isinstance(resolved, bool): + return resolved + return None + + def _resolve_constant(self, node: ast.AST) -> str | bool | None: + if isinstance(node, ast.Constant) and isinstance(node.value, (str, bool)): + return node.value + + if isinstance(node, ast.Name): + resolved = self._constants.get(node.id) + if isinstance(resolved, (str, bool)): + return resolved + + return None + + @staticmethod + def _get_callable_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + + if isinstance(node, ast.Attribute): + return node.attr + + return None + + @staticmethod + def _iter_assigned_names(target: ast.AST) -> list[str]: + if isinstance(target, ast.Name): + return [target.id] + + if isinstance(target, (ast.Tuple, ast.List)): + names: list[str] = [] + for element in target.elts: + names.extend(SettingsFieldCollector._iter_assigned_names(element)) + return names + + return [] + + +class ConfigAccessVisitor(ast.NodeVisitor): + """Scan a module AST for config access that bypasses app_config.get(...).""" + + def __init__( + self, + *, + path: Path, + registered_keys: set[str], + registered_env_vars: set[str], + ) -> None: + self.path = path + self.registered_keys = registered_keys + self.registered_env_vars = registered_env_vars + self.violations: list[GuardrailViolation] = [] + self._allow_bootstrap_env_access = path in _BOOTSTRAP_ENV_ACCESS_ALLOWLIST + self._bootstrap_env_key_allowlist = { + key for allowed_path, key in _BOOTSTRAP_ENV_ACCESS_KEY_ALLOWLIST if allowed_path == path + } + self._allow_raw_config_reads = path in _RAW_CONFIG_READ_ALLOWLIST + self._string_scopes: list[dict[str, str]] = [{}] + self._config_alias_scopes: list[set[str]] = [set()] + self._env_module_aliases: set[str] = set() + self._load_config_names = {"load_config_file"} + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + if alias.name == "shelfmark.config.env" and alias.asname: + self._env_module_aliases.add(alias.asname) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module == "shelfmark.core.settings_registry": + for alias in node.names: + if alias.name == "load_config_file": + self._load_config_names.add(alias.asname or alias.name) + return + + if node.module == "shelfmark.config": + for alias in node.names: + if alias.name == "env": + self._env_module_aliases.add(alias.asname or alias.name) + return + + if node.module == "shelfmark.config.env": + if self._allow_bootstrap_env_access: + return + for alias in node.names: + imported_name = alias.name + if imported_name in self.registered_keys: + self._record_violation( + node, + "direct env-module import", + imported_name, + ) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._push_scope() + for statement in node.body: + self.visit(statement) + self._pop_scope() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_scoped_body(node.body) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_scoped_body(node.body) + + def visit_Assign(self, node: ast.Assign) -> None: + resolved_string = self._resolve_string(node.value) + load_config_alias = self._is_load_config_call(node.value) + + for target in node.targets: + for name in self._iter_assigned_names(target): + if resolved_string is not None: + self._string_scopes[-1][name] = resolved_string + else: + self._string_scopes[-1].pop(name, None) + + if load_config_alias: + self._config_alias_scopes[-1].add(name) + else: + self._config_alias_scopes[-1].discard(name) + + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + if node.value is None: + return + + resolved_string = self._resolve_string(node.value) + load_config_alias = self._is_load_config_call(node.value) + + for name in self._iter_assigned_names(node.target): + if resolved_string is not None: + self._string_scopes[-1][name] = resolved_string + else: + self._string_scopes[-1].pop(name, None) + + if load_config_alias: + self._config_alias_scopes[-1].add(name) + else: + self._config_alias_scopes[-1].discard(name) + + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + if self._allow_bootstrap_env_access: + return + + if isinstance(node.value, ast.Name): + if ( + node.value.id in self._env_module_aliases + and node.attr in self.registered_keys + and node.attr not in self._bootstrap_env_key_allowlist + ): + self._record_violation(node, "direct env-module access", node.attr) + + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + env_var = self._get_direct_env_lookup(node) + if env_var is not None and not self._allow_bootstrap_env_access: + self._record_violation(node, "direct env lookup", env_var) + + config_key = self._get_raw_config_lookup(node) + if config_key is not None and not self._allow_raw_config_reads: + self._record_violation(node, "raw config lookup", config_key) + + self.generic_visit(node) + + def visit_Subscript(self, node: ast.Subscript) -> None: + if isinstance(node.ctx, ast.Load): + env_var = self._get_direct_env_subscript(node) + if env_var is not None and not self._allow_bootstrap_env_access: + self._record_violation(node, "direct env lookup", env_var) + + config_key = self._get_raw_config_subscript(node) + if config_key is not None and not self._allow_raw_config_reads: + self._record_violation(node, "raw config lookup", config_key) + + self.generic_visit(node) + + def visit_Compare(self, node: ast.Compare) -> None: + if self._allow_raw_config_reads: + self.generic_visit(node) + return + + if len(node.ops) != 1 or len(node.comparators) != 1: + self.generic_visit(node) + return + + key = self._resolve_string(node.left) + comparator = node.comparators[0] + if ( + isinstance(node.ops[0], ast.In) + and key in self.registered_keys + and self._is_load_config_target(comparator) + ): + self._record_violation(node, "raw config lookup", key) + + self.generic_visit(node) + + def _visit_scoped_body(self, body: list[ast.stmt]) -> None: + self._push_scope() + for statement in body: + self.visit(statement) + self._pop_scope() + + def _push_scope(self) -> None: + self._string_scopes.append({}) + self._config_alias_scopes.append(set()) + + def _pop_scope(self) -> None: + self._string_scopes.pop() + self._config_alias_scopes.pop() + + def _resolve_string(self, node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + + if isinstance(node, ast.Name): + for scope in reversed(self._string_scopes): + if node.id in scope: + return scope[node.id] + + return None + + def _is_load_config_call(self, node: ast.AST) -> bool: + return self._resolve_load_config_call(node) is not None + + def _resolve_load_config_call(self, node: ast.AST) -> str | None: + if not isinstance(node, ast.Call): + return None + + func = node.func + if isinstance(func, ast.Name) and func.id in self._load_config_names and node.args: + return self._resolve_string(node.args[0]) + + if isinstance(func, ast.Attribute) and func.attr == "load_config_file" and node.args: + return self._resolve_string(node.args[0]) + + return None + + def _is_load_config_target(self, node: ast.AST) -> bool: + if self._resolve_load_config_call(node) is not None: + return True + + if isinstance(node, ast.Name): + return any(node.id in scope for scope in reversed(self._config_alias_scopes)) + + return False + + def _get_direct_env_lookup(self, node: ast.Call) -> str | None: + func = node.func + if isinstance(func, ast.Attribute): + if ( + isinstance(func.value, ast.Name) + and func.value.id == "os" + and func.attr == "getenv" + and node.args + ): + env_var = self._resolve_string(node.args[0]) + if env_var in self.registered_env_vars: + return env_var + + if func.attr == "get" and node.args: + env_var = self._resolve_string(node.args[0]) + if env_var in self.registered_env_vars and self._is_os_environ(func.value): + return env_var + + return None + + def _get_raw_config_lookup(self, node: ast.Call) -> str | None: + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "get" or not node.args: + return None + + key = self._resolve_string(node.args[0]) + if key not in self.registered_keys: + return None + + if self._is_load_config_target(func.value): + return key + + return None + + def _get_direct_env_subscript(self, node: ast.Subscript) -> str | None: + if not self._is_os_environ(node.value): + return None + + env_var = self._resolve_string(node.slice) + if env_var in self.registered_env_vars: + return env_var + + return None + + def _get_raw_config_subscript(self, node: ast.Subscript) -> str | None: + if not self._is_load_config_target(node.value): + return None + + key = self._resolve_string(node.slice) + if key in self.registered_keys: + return key + + return None + + @staticmethod + def _is_os_environ(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "os" + and node.attr == "environ" + ) + + @staticmethod + def _iter_assigned_names(target: ast.AST) -> list[str]: + if isinstance(target, ast.Name): + return [target.id] + + if isinstance(target, (ast.Tuple, ast.List)): + names: list[str] = [] + for element in target.elts: + names.extend(ConfigAccessVisitor._iter_assigned_names(element)) + return names + + return [] + + def _record_violation(self, node: ast.AST, access_type: str, key: str) -> None: + self.violations.append( + GuardrailViolation( + path=self.path, + line=node.lineno, + message=( + f"{access_type} for '{key}' bypasses app_config.get(...) " + "or the config singleton" + ), + ) + ) + + +def _load_registered_settings() -> tuple[set[str], set[str]]: + collector = SettingsFieldCollector() + + for root in _SETTINGS_SOURCE_ROOTS: + for file_path in sorted(root.rglob("*.py")): + module_ast = ast.parse(file_path.read_text(), filename=str(file_path)) + collector.visit(module_ast) + + return collector.registered_keys, collector.registered_env_vars + + +def _scan_runtime_modules() -> list[GuardrailViolation]: + registered_keys, registered_env_vars = _load_registered_settings() + violations: list[GuardrailViolation] = [] + + for file_path in sorted(_PACKAGE_ROOT.rglob("*.py")): + relative_path = file_path.relative_to(_REPO_ROOT) + module_ast = ast.parse(file_path.read_text(), filename=str(relative_path)) + visitor = ConfigAccessVisitor( + path=relative_path, + registered_keys=registered_keys, + registered_env_vars=registered_env_vars, + ) + visitor.visit(module_ast) + violations.extend(visitor.violations) + + return violations + + +def test_runtime_code_uses_config_singleton_for_registered_settings() -> None: + violations = _scan_runtime_modules() + + assert not violations, "\n".join( + f"{violation.path}:{violation.line}: {violation.message}" for violation in violations + ) diff --git a/tests/core/test_config_api.py b/tests/core/test_config_api.py index 4d903f0..3bb9121 100644 --- a/tests/core/test_config_api.py +++ b/tests/core/test_config_api.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import uuid from pathlib import Path from unittest.mock import patch @@ -32,34 +31,97 @@ def _set_session(client, *, user_id: str, db_user_id: int, is_admin: bool) -> No sess["is_admin"] = is_admin -def _create_user(main_module, *, prefix: str, role: str = "user") -> dict: - username = f"{prefix}-{uuid.uuid4().hex[:8]}" - return main_module.user_db.create_user(username=username, role=role) +def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client): + _set_session(client, user_id="reader-1", db_user_id=42, is_admin=False) + calls: list[tuple[str, int | None]] = [] -def test_config_includes_release_source_links_toggle(main_module, client): - user = _create_user(main_module, prefix="reader") - _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + def fake_get(key, default=None, user_id=None): + calls.append((key, user_id)) + values = { + "SHOW_RELEASE_SOURCE_LINKS": False, + "SHOW_COMBINED_SELECTOR": False, + "SEARCH_MODE": "universal", + "METADATA_PROVIDER": "openlibrary", + "METADATA_PROVIDER_AUDIOBOOK": "", + "DEFAULT_RELEASE_SOURCE": "prowlarr", + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay", + "DOWNLOAD_TO_BROWSER_CONTENT_TYPES": ["book", "audiobook"], + "AUTO_OPEN_DOWNLOADS_SIDEBAR": False, + "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD": True, + "AA_DEFAULT_SORT": "newest", + } + return values.get(key, default) - def fake_get(key, default=None, user_id=None): # noqa: ANN001 - if key == "SHOW_RELEASE_SOURCE_LINKS": - return False - return default - - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module.app_config, "get", side_effect=fake_get): - with patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=[]): - with patch( - "shelfmark.metadata_providers.get_provider_search_fields", return_value=[] - ): - with patch( - "shelfmark.metadata_providers.get_provider_default_sort", - return_value="relevance", - ): - resp = client.get("/api/config") + with ( + patch.object(main_module.app_config, "get", side_effect=fake_get), + patch("shelfmark.config.env._is_config_dir_writable", return_value=True), + patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True), + patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=["sort-a"]), + patch("shelfmark.metadata_providers.get_provider_search_fields", return_value=["field-a"]), + patch("shelfmark.metadata_providers.get_provider_default_sort", return_value="relevance"), + ): + resp = client.get("/api/config") assert resp.status_code == 200 - assert resp.json["show_release_source_links"] is False + data = resp.get_json() + assert data["show_release_source_links"] is False + assert data["show_combined_selector"] is False + assert data["search_mode"] == "universal" + assert data["metadata_sort_options"] == ["sort-a"] + assert data["metadata_search_fields"] == ["field-a"] + assert data["default_release_source"] == "prowlarr" + assert data["default_release_source_audiobook"] == "audiobookbay" + assert data["download_to_browser_content_types"] == ["book", "audiobook"] + assert data["settings_enabled"] is True + assert data["metadata_default_sort"] == "relevance" + + assert ("SHOW_RELEASE_SOURCE_LINKS", None) in calls + assert ("SHOW_COMBINED_SELECTOR", 42) in calls + assert ("DOWNLOAD_TO_BROWSER_CONTENT_TYPES", 42) in calls + + +def test_config_endpoint_falls_back_to_audiobook_metadata_provider(main_module, client): + _set_session(client, user_id="reader-2", db_user_id=77, is_admin=False) + + provider_calls: list[str] = [] + + def fake_get(key, default=None, user_id=None): + values = { + "METADATA_PROVIDER": "", + "METADATA_PROVIDER_AUDIOBOOK": "audiobook-search", + "SHOW_RELEASE_SOURCE_LINKS": True, + } + return values.get(key, default) + + def sort_options(provider: str): + provider_calls.append(provider) + return [f"{provider}-sort"] + + def search_fields(provider: str): + provider_calls.append(provider) + return [f"{provider}-field"] + + def default_sort(provider: str): + provider_calls.append(provider) + return f"{provider}-default" + + with ( + patch.object(main_module.app_config, "get", side_effect=fake_get), + patch("shelfmark.config.env._is_config_dir_writable", return_value=True), + patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True), + patch("shelfmark.metadata_providers.get_provider_sort_options", side_effect=sort_options), + patch("shelfmark.metadata_providers.get_provider_search_fields", side_effect=search_fields), + patch("shelfmark.metadata_providers.get_provider_default_sort", side_effect=default_sort), + ): + resp = client.get("/api/config") + + assert resp.status_code == 200 + data = resp.get_json() + assert data["metadata_sort_options"] == ["audiobook-search-sort"] + assert data["metadata_search_fields"] == ["audiobook-search-field"] + assert data["metadata_default_sort"] == "audiobook-search-default" + assert provider_calls == ["audiobook-search", "audiobook-search", "audiobook-search"] def test_frontend_dist_resolves_from_repo_root(main_module): diff --git a/tests/core/test_config_user_overrides.py b/tests/core/test_config_user_overrides.py index c79b493..4b2f8e1 100644 --- a/tests/core/test_config_user_overrides.py +++ b/tests/core/test_config_user_overrides.py @@ -1,24 +1,26 @@ -"""Tests for Config.get per-user override precedence.""" +"""Tests for user override precedence and effective user-preference payloads.""" + +from __future__ import annotations from types import SimpleNamespace +from unittest.mock import patch from shelfmark.core.config import config -class _DummyField: - def __init__(self, env_supported: bool, user_overridable: bool): - self.env_supported = env_supported - self.user_overridable = user_overridable +def _download_field(key: str): + import shelfmark.config.settings # noqa: F401 + from shelfmark.core import settings_registry + + return settings_registry.get_settings_field_map()[key][0] def test_get_prefers_env_over_user_override(monkeypatch): + field = _download_field("DESTINATION") + monkeypatch.setattr(config, "_ensure_loaded", lambda: None) monkeypatch.setattr(config, "_cache", {"DESTINATION": "/env/books"}) - monkeypatch.setattr( - config, - "_field_map", - {"DESTINATION": (_DummyField(env_supported=True, user_overridable=True), "downloads")}, - ) + monkeypatch.setattr(config, "_field_map", {"DESTINATION": (field, "downloads")}) monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books") monkeypatch.setattr( "shelfmark.core.config._get_registry", @@ -28,14 +30,12 @@ def test_get_prefers_env_over_user_override(monkeypatch): assert config.get("DESTINATION", "/default", user_id=10) == "/env/books" -def test_get_uses_user_override_when_not_env(monkeypatch): +def test_get_uses_user_override_when_not_from_env(monkeypatch): + field = _download_field("DESTINATION") + monkeypatch.setattr(config, "_ensure_loaded", lambda: None) monkeypatch.setattr(config, "_cache", {"DESTINATION": "/global/books"}) - monkeypatch.setattr( - config, - "_field_map", - {"DESTINATION": (_DummyField(env_supported=True, user_overridable=True), "downloads")}, - ) + monkeypatch.setattr(config, "_field_map", {"DESTINATION": (field, "downloads")}) monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books") monkeypatch.setattr( "shelfmark.core.config._get_registry", @@ -46,12 +46,14 @@ def test_get_uses_user_override_when_not_env(monkeypatch): def test_get_ignores_user_override_for_non_overridable_field(monkeypatch): + field = _download_field("FILE_ORGANIZATION") + monkeypatch.setattr(config, "_ensure_loaded", lambda: None) monkeypatch.setattr(config, "_cache", {"FILE_ORGANIZATION": "rename"}) monkeypatch.setattr( config, "_field_map", - {"FILE_ORGANIZATION": (_DummyField(env_supported=True, user_overridable=False), "downloads")}, + {"FILE_ORGANIZATION": (field, "downloads")}, ) monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "organize") monkeypatch.setattr( @@ -62,13 +64,15 @@ def test_get_ignores_user_override_for_non_overridable_field(monkeypatch): assert config.get("FILE_ORGANIZATION", "rename", user_id=10) == "rename" -def test_get_respects_empty_user_override_for_destination_audiobook(monkeypatch): +def test_get_keeps_empty_string_override(monkeypatch): + field = _download_field("DESTINATION_AUDIOBOOK") + monkeypatch.setattr(config, "_ensure_loaded", lambda: None) monkeypatch.setattr(config, "_cache", {"DESTINATION_AUDIOBOOK": "/global/audiobooks"}) monkeypatch.setattr( config, "_field_map", - {"DESTINATION_AUDIOBOOK": (_DummyField(env_supported=True, user_overridable=True), "downloads")}, + {"DESTINATION_AUDIOBOOK": (field, "downloads")}, ) monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "") monkeypatch.setattr( @@ -77,3 +81,65 @@ def test_get_respects_empty_user_override_for_destination_audiobook(monkeypatch) ) assert config.get("DESTINATION_AUDIOBOOK", "/default", user_id=10) == "" + + +def test_build_user_preferences_payload_reports_effective_sources(monkeypatch): + import shelfmark.config.settings # noqa: F401 + from shelfmark.core import settings_registry + from shelfmark.core.user_settings_overrides import build_user_preferences_payload + + user_db = SimpleNamespace( + get_user_settings=lambda user_id: { + "DESTINATION": "/user/books", + } + ) + + def fake_get(key, default=None, user_id=None): + values = { + "DESTINATION": "/global/books", + "BOOKS_OUTPUT_MODE": "folder", + "EMAIL_RECIPIENT": "global@example.com", + } + return values.get(key, default) + + with ( + patch( + "shelfmark.core.user_settings_overrides.get_settings_registry", + return_value=settings_registry, + ), + patch( + "shelfmark.core.user_settings_overrides.load_config_file", + return_value={ + "DESTINATION": "/global/books", + "BOOKS_OUTPUT_MODE": "folder", + "EMAIL_RECIPIENT": "global@example.com", + }, + ), + patch.object(config, "get", side_effect=fake_get), + patch.object( + settings_registry, + "is_value_from_env", + side_effect=lambda field: field.key == "BOOKS_OUTPUT_MODE", + ), + ): + payload = build_user_preferences_payload(user_db, 7, "downloads") + + assert payload["tab"] == "downloads" + assert payload["userOverrides"] == {"DESTINATION": "/user/books"} + assert payload["globalValues"]["DESTINATION"] == "/global/books" + assert payload["effective"]["DESTINATION"] == { + "value": "/user/books", + "source": "user_override", + } + assert payload["effective"]["BOOKS_OUTPUT_MODE"] == { + "value": "folder", + "source": "env_var", + } + assert payload["effective"]["EMAIL_RECIPIENT"] == { + "value": "global@example.com", + "source": "global_config", + } + + fields_by_key = {field["key"]: field for field in payload["fields"]} + assert fields_by_key["DESTINATION"]["fromEnv"] is False + assert fields_by_key["BOOKS_OUTPUT_MODE"]["fromEnv"] is True diff --git a/tests/core/test_destination_file_organization.py b/tests/core/test_destination_file_organization.py new file mode 100644 index 0000000..60b2854 --- /dev/null +++ b/tests/core/test_destination_file_organization.py @@ -0,0 +1,131 @@ +"""Tests for the current destination and file-organization policy helpers.""" + +from pathlib import Path + + +def test_get_destination_uses_current_destination(monkeypatch): + import shelfmark.core.utils as utils + from shelfmark.core.config import config + + monkeypatch.setattr( + config, + "get", + lambda key, default=None, **_kwargs: { + "DESTINATION": "/srv/books", + }.get(key, default), + ) + + assert utils.get_destination() == Path("/srv/books") + + +def test_get_destination_audiobook_falls_back_to_books_destination(monkeypatch): + import shelfmark.core.utils as utils + from shelfmark.core.config import config + + monkeypatch.setattr( + config, + "get", + lambda key, default=None, **_kwargs: { + "DESTINATION": "/srv/books", + "DESTINATION_AUDIOBOOK": "", + }.get(key, default), + ) + + assert utils.get_destination(is_audiobook=True) == Path("/srv/books") + + +def test_get_destination_falls_back_to_legacy_ingest_dir(monkeypatch): + import shelfmark.core.utils as utils + from shelfmark.core.config import config + + monkeypatch.setattr( + config, + "get", + lambda key, default=None, **_kwargs: { + "DESTINATION": "", + "INGEST_DIR": "/legacy/ingest", + }.get(key, default), + ) + + assert utils.get_destination() == Path("/legacy/ingest") + + +def test_get_file_organization_uses_current_keys(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: { + "FILE_ORGANIZATION": "organize", + "FILE_ORGANIZATION_AUDIOBOOK": "none", + }.get(key, default), + ) + + assert policy.get_file_organization(is_audiobook=False) == "organize" + assert policy.get_file_organization(is_audiobook=True) == "none" + + +def test_get_file_organization_ignores_pre_release_processing_mode_keys(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: { + "FILE_ORGANIZATION": "", + "PROCESSING_MODE": "library", + }.get(key, default), + ) + + assert policy.get_file_organization(is_audiobook=False) == "rename" + + +def test_get_template_uses_current_template_keys(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: { + "TEMPLATE_RENAME": "{Author} - {Title}", + "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + }.get(key, default), + ) + + assert ( + policy.get_template(is_audiobook=False, organization_mode="rename") == "{Author} - {Title}" + ) + assert ( + policy.get_template(is_audiobook=True, organization_mode="organize") + == "{Author}/{Title}{ - PartNumber}" + ) + + +def test_get_template_defaults_when_missing_and_ignores_pre_release_library_templates(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: { + "TEMPLATE_ORGANIZE": "", + "TEMPLATE_RENAME": "", + "LIBRARY_TEMPLATE": "{Legacy}/{Template}", + "TEMPLATE_AUDIOBOOK_RENAME": "", + "LIBRARY_TEMPLATE_AUDIOBOOK": "{LegacyAudio}/{Template}", + }.get(key, default), + ) + + assert ( + policy.get_template(is_audiobook=False, organization_mode="organize") + == "{Author}/{Title} ({Year})" + ) + assert ( + policy.get_template(is_audiobook=False, organization_mode="rename") + == "{Author} - {Title} ({Year})" + ) + assert ( + policy.get_template(is_audiobook=True, organization_mode="rename") + == "{Author} - {Title} ({Year})" + ) diff --git a/tests/core/test_download_api_guardrails.py b/tests/core/test_download_api_guardrails.py index 14c8a07..0afde78 100644 --- a/tests/core/test_download_api_guardrails.py +++ b/tests/core/test_download_api_guardrails.py @@ -282,7 +282,9 @@ class TestReleaseDownloadEndpointGuardrails: assert resp.get_json() == {"error": "User not found"} mock_queue_release.assert_not_called() - def test_on_behalf_release_download_returns_503_when_user_db_unavailable(self, main_module, client): + def test_on_behalf_release_download_returns_503_when_user_db_unavailable( + self, main_module, client + ): admin_user = _create_user(main_module, prefix="admin", role="admin") _set_authenticated_session( client, @@ -326,7 +328,9 @@ class TestCancelDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + with patch.object( + main_module.backend, "cancel_download", return_value=True + ) as mock_cancel: resp = client.delete("/api/download/direct-task-1/cancel") assert resp.status_code == 200 @@ -352,7 +356,9 @@ class TestCancelDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + with patch.object( + main_module.backend, "cancel_download", return_value=True + ) as mock_cancel: resp = client.delete("/api/download/owned-task-1/cancel") assert resp.status_code == 403 @@ -397,7 +403,9 @@ class TestCancelDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + with patch.object( + main_module.backend, "cancel_download", return_value=True + ) as mock_cancel: resp = client.delete("/api/download/requested-task-1/cancel") assert resp.status_code == 403 @@ -442,7 +450,9 @@ class TestCancelDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + with patch.object( + main_module.backend, "cancel_download", return_value=True + ) as mock_cancel: resp = client.delete("/api/download/requested-task-2/cancel") assert resp.status_code == 200 @@ -487,14 +497,18 @@ class TestRetryDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + with patch.object( + main_module.backend, "retry_download", return_value=(True, None) + ) as mock_retry: resp = client.post("/api/download/direct-task-retry-1/retry") assert resp.status_code == 200 assert resp.get_json() == {"status": "queued", "book_id": "direct-task-retry-1"} mock_retry.assert_called_once_with("direct-task-retry-1") - def test_owner_can_retry_persisted_direct_download_when_live_task_is_missing(self, main_module, client): + def test_owner_can_retry_persisted_direct_download_when_live_task_is_missing( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_authenticated_session( client, @@ -561,7 +575,9 @@ class TestRetryDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + with patch.object( + main_module.backend, "retry_download", return_value=(True, None) + ) as mock_retry: resp = client.post("/api/download/owned-task-retry-1/retry") assert resp.status_code == 403 @@ -587,7 +603,9 @@ class TestRetryDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + with patch.object( + main_module.backend, "retry_download", return_value=(True, None) + ) as mock_retry: resp = client.post("/api/download/requested-retry-1/retry") assert resp.status_code == 403 @@ -632,14 +650,18 @@ class TestRetryDownloadEndpointGuardrails: with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.backend.book_queue, "get_task", return_value=task): - with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + with patch.object( + main_module.backend, "retry_download", return_value=(True, None) + ) as mock_retry: resp = client.post("/api/download/requested-retry-2/retry") assert resp.status_code == 403 assert resp.get_json()["code"] == "requested_download_retry_forbidden" mock_retry.assert_not_called() - def test_retry_allows_request_linked_postprocess_error_with_staged_file(self, main_module, client, tmp_path): + def test_retry_allows_request_linked_postprocess_error_with_staged_file( + self, main_module, client, tmp_path + ): user = _create_user(main_module, prefix="requester") _set_authenticated_session( client, @@ -666,7 +688,9 @@ class TestRetryDownloadEndpointGuardrails: "get_task_status", return_value=main_module.QueueStatus.ERROR, ): - with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + with patch.object( + main_module.backend, "retry_download", return_value=(True, None) + ) as mock_retry: resp = client.post("/api/download/requested-retry-postprocess-1/retry") assert resp.status_code == 200 diff --git a/tests/core/test_download_processing.py b/tests/core/test_download_processing.py index 95f952d..35f701f 100644 --- a/tests/core/test_download_processing.py +++ b/tests/core/test_download_processing.py @@ -9,20 +9,20 @@ Covers: import json import os -import pytest import shutil -import tempfile from pathlib import Path from threading import Event -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch + +import pytest from shelfmark.core.models import DownloadTask, SearchMode - # ============================================================================= # Fixtures # ============================================================================= + @pytest.fixture def sample_task(): """Create a sample DownloadTask for testing.""" @@ -84,6 +84,7 @@ def _sync_core_config(mock_config, mock_core_config, mock_archive_config=None): # _atomic_copy Tests # ============================================================================= + class TestAtomicCopy: """Tests for _atomic_copy() function.""" @@ -195,7 +196,7 @@ class TestAtomicCopy: dest = tmp_path / "dest.txt" # Simulate shutil.copy2 failure mid-copy - with patch('shutil.copy2', side_effect=IOError("Disk full")): + with patch("shutil.copy2", side_effect=OSError("Disk full")): with pytest.raises(IOError): _atomic_copy(source, dest) @@ -205,6 +206,7 @@ class TestAtomicCopy: def test_copy_recovers_when_metadata_step_hits_enoent(self, tmp_path): """Treat ENOENT from copy2 metadata as recoverable if bytes already copied.""" import errno + from shelfmark.download.fs import atomic_copy as _atomic_copy source = tmp_path / "source.txt" @@ -298,6 +300,7 @@ class TestAtomicCopy: # process_directory Tests # ============================================================================= + class TestProcessDirectory: """Tests for process_directory() function.""" @@ -309,13 +312,17 @@ class TestProcessDirectory: directory.mkdir() (directory / "book.epub").write_bytes(b"epub content") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -340,13 +347,17 @@ class TestProcessDirectory: (directory / "book1.epub").write_bytes(b"epub1") (directory / "book2.epub").write_bytes(b"epub2") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -367,12 +378,16 @@ class TestProcessDirectory: # Use a file type that isn't trackable (not epub, pdf, txt, etc.) (directory / "readme.log").write_text("not a book") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -393,12 +408,16 @@ class TestProcessDirectory: directory.mkdir() (directory / "book.pdf").write_bytes(b"pdf content") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], # PDF not supported - "FILE_ORGANIZATION": "none", - }.get(key, default)) + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], # PDF not supported + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -420,13 +439,17 @@ class TestProcessDirectory: directory.mkdir() (directory / "random_name.epub").write_bytes(b"content") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = True - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "rename", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "rename", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -449,13 +472,17 @@ class TestProcessDirectory: (directory / "Part 1.epub").write_bytes(b"part1") (directory / "Part 2.epub").write_bytes(b"part2") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = True # Ignored for multi-file - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -478,13 +505,17 @@ class TestProcessDirectory: subdir.mkdir(parents=True) (subdir / "book.epub").write_bytes(b"content") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -504,15 +535,21 @@ class TestProcessDirectory: directory.mkdir() (directory / "book.epub").write_bytes(b"content") - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('shelfmark.download.postprocess.transfer.atomic_move', side_effect=RuntimeError("Move failed")): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch( + "shelfmark.download.postprocess.transfer.atomic_move", + side_effect=RuntimeError("Move failed"), + ), + ): mock_config.USE_BOOK_TITLE = False - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "SUPPORTED_FORMATS": ["epub"], - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "SUPPORTED_FORMATS": ["epub"], + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) final_paths, error = process_directory( @@ -532,12 +569,15 @@ class TestProcessDirectory: # _post_process_download Tests # ============================================================================= + class TestPostProcessDownload: """Tests for _post_process_download() function.""" def test_simple_file_move_to_ingest(self, temp_dirs, sample_direct_task): """Simple file is moved to ingest directory.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"epub content") @@ -545,9 +585,10 @@ class TestPostProcessDownload: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) @@ -570,7 +611,9 @@ class TestPostProcessDownload: def test_uses_formatted_filename(self, temp_dirs, sample_direct_task): """Uses task title when USE_BOOK_TITLE enabled.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "random.epub" temp_file.write_bytes(b"content") @@ -578,9 +621,10 @@ class TestPostProcessDownload: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = True mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) @@ -600,7 +644,9 @@ class TestPostProcessDownload: def test_organize_mode_for_universal(self, temp_dirs, sample_task): """Universal mode organizes when configured.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) library = temp_dirs["base"] / "library" library.mkdir() @@ -610,17 +656,20 @@ class TestPostProcessDownload: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = True mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": str(library), - "FILE_ORGANIZATION": "organize", - "TEMPLATE_ORGANIZE": "{Author}/{Title}", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": str(library), + "FILE_ORGANIZATION": "organize", + "TEMPLATE_ORGANIZE": "{Author}/{Title}", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) result = _post_process_download( @@ -637,7 +686,9 @@ class TestPostProcessDownload: def test_direct_mode_uses_ingest(self, temp_dirs, sample_direct_task): """Direct mode keeps ingest destination when not organizing.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) library = temp_dirs["base"] / "library" library.mkdir() @@ -647,16 +698,19 @@ class TestPostProcessDownload: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": str(temp_dirs["ingest"]), - "FILE_ORGANIZATION": "none", - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": str(temp_dirs["ingest"]), + "FILE_ORGANIZATION": "none", + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) result = _post_process_download( @@ -673,7 +727,9 @@ class TestPostProcessDownload: def test_cancellation_before_ingest(self, temp_dirs, sample_direct_task): """Respects cancellation before final move.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -682,9 +738,10 @@ class TestPostProcessDownload: cancel_flag = Event() cancel_flag.set() # Already cancelled - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) @@ -707,7 +764,9 @@ class TestPostProcessDownload: def test_audiobook_uses_dedicated_ingest(self, temp_dirs, sample_task): """Audiobooks use dedicated ingest directory when configured.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) audiobook_ingest = temp_dirs["base"] / "audiobook_ingest" audiobook_ingest.mkdir() @@ -719,17 +778,20 @@ class TestPostProcessDownload: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = None _sync_core_config(mock_config, mock_config) - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": str(temp_dirs["ingest"]), - "INGEST_DIR": str(temp_dirs["ingest"]), - "DESTINATION_AUDIOBOOK": str(audiobook_ingest), - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": str(temp_dirs["ingest"]), + "INGEST_DIR": str(temp_dirs["ingest"]), + "DESTINATION_AUDIOBOOK": str(audiobook_ingest), + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) result = _post_process_download( @@ -748,24 +810,29 @@ class TestPostProcessDownload: # Custom Script Execution Tests # ============================================================================= + class TestCustomScriptExecution: """Tests for custom script execution in post-processing.""" def test_runs_custom_script(self, temp_dirs, sample_direct_task): """Runs custom script when configured.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download import subprocess + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) @@ -791,7 +858,9 @@ class TestCustomScriptExecution: def test_runs_custom_script_with_json_payload_on_stdin(self, temp_dirs, sample_direct_task): """Sends a JSON payload to the custom script via stdin when enabled.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -799,10 +868,11 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) @@ -835,9 +905,13 @@ class TestCustomScriptExecution: assert payload["paths"]["target"] == str(result_path) assert payload["paths"]["final_paths"] == [str(result_path)] - def test_runs_custom_script_for_booklore_output_with_json_payload(self, temp_dirs, sample_direct_task): + def test_runs_custom_script_for_booklore_output_with_json_payload( + self, temp_dirs, sample_direct_task + ): """Runs the custom script hook after a successful Booklore upload.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -847,26 +921,29 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('shelfmark.download.outputs.booklore.booklore_login', return_value="token"), \ - patch('shelfmark.download.outputs.booklore.booklore_upload_file'), \ - patch('shelfmark.download.outputs.booklore.booklore_refresh_library'), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), + patch("shelfmark.download.outputs.booklore.booklore_upload_file"), + patch("shelfmark.download.outputs.booklore.booklore_refresh_library"), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "BOOKS_OUTPUT_MODE": "booklore", - "BOOKLORE_HOST": "http://booklore:6060", - "BOOKLORE_USERNAME": "user", - "BOOKLORE_PASSWORD": "pass", - "BOOKLORE_LIBRARY_ID": 1, - "BOOKLORE_PATH_ID": 2, - "CUSTOM_SCRIPT_JSON_PAYLOAD": True, - }.get(key, default)) + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "BOOKS_OUTPUT_MODE": "booklore", + "BOOKLORE_HOST": "http://booklore:6060", + "BOOKLORE_USERNAME": "user", + "BOOKLORE_PASSWORD": "pass", + "BOOKLORE_LIBRARY_ID": 1, + "BOOKLORE_PATH_ID": 2, + "CUSTOM_SCRIPT_JSON_PAYLOAD": True, + }.get(key, default) + ) _sync_core_config(mock_config, mock_config) mock_run.return_value = MagicMock(stdout="", returncode=0) @@ -893,8 +970,9 @@ class TestCustomScriptExecution: def test_runs_custom_script_with_relative_path_mode(self, temp_dirs, sample_direct_task): """Runs custom script with a destination-relative path when configured.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download - import subprocess + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -902,10 +980,11 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) @@ -934,7 +1013,9 @@ class TestCustomScriptExecution: def test_runs_custom_script_for_directory_download_once(self, temp_dirs): """Runs custom script once after transferring a directory download.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) download_dir = temp_dirs["staging"] / "release" download_dir.mkdir() @@ -954,14 +1035,17 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) - mock_config.get = _mock_destination_config(temp_dirs["ingest"], {"FILE_ORGANIZATION_AUDIOBOOK": "none"}) + mock_config.get = _mock_destination_config( + temp_dirs["ingest"], {"FILE_ORGANIZATION_AUDIOBOOK": "none"} + ) _sync_core_config(mock_config, mock_config) mock_run.return_value = MagicMock(stdout="", returncode=0) @@ -981,7 +1065,9 @@ class TestCustomScriptExecution: def test_script_not_found_error(self, temp_dirs, sample_direct_task): """Returns error when script not found.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -989,10 +1075,11 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run', side_effect=FileNotFoundError("not found")): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run", side_effect=FileNotFoundError("not found")), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/nonexistent/script.sh" _sync_core_config(mock_config, mock_config) @@ -1011,7 +1098,9 @@ class TestCustomScriptExecution: def test_script_not_executable_error(self, temp_dirs, sample_direct_task): """Returns error when script not executable.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") @@ -1019,10 +1108,11 @@ class TestCustomScriptExecution: status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run', side_effect=PermissionError("not executable")): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run", side_effect=PermissionError("not executable")), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) @@ -1041,19 +1131,23 @@ class TestCustomScriptExecution: def test_script_timeout_error(self, temp_dirs, sample_direct_task): """Returns error when script times out.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download import subprocess + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run', side_effect=subprocess.TimeoutExpired("script", 300)): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run", side_effect=subprocess.TimeoutExpired("script", 300)), + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) @@ -1072,19 +1166,23 @@ class TestCustomScriptExecution: def test_script_nonzero_exit_error(self, temp_dirs, sample_direct_task): """Returns error when script exits non-zero.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download import subprocess + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + temp_file = temp_dirs["staging"] / "book.epub" temp_file.write_bytes(b"content") status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ - patch('subprocess.run') as mock_run: - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", temp_dirs["staging"]), + patch("subprocess.run") as mock_run, + ): mock_config.USE_BOOK_TITLE = False mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_core_config(mock_config, mock_config) diff --git a/tests/core/test_hardlink.py b/tests/core/test_hardlink.py index 79837e5..36eab0c 100644 --- a/tests/core/test_hardlink.py +++ b/tests/core/test_hardlink.py @@ -12,13 +12,14 @@ Two approaches to preserve torrent files for seeding: """ import os -import pytest import shutil import tempfile from pathlib import Path from threading import Event from unittest.mock import MagicMock, patch +import pytest + from shelfmark.core.naming import same_filesystem @@ -29,23 +30,27 @@ def _run_organize_post_process( hardlink_enabled: bool = True, same_fs: bool = True, ): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) status_cb = MagicMock() cancel_flag = Event() - with patch('shelfmark.core.config.config') as mock_config, \ - patch('shelfmark.download.postprocess.transfer.same_filesystem', return_value=same_fs): - + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_fs), + ): mock_config.CUSTOM_SCRIPT = None - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": str(library), - "FILE_ORGANIZATION": "organize", - "HARDLINK_TORRENTS": hardlink_enabled, - "HARDLINK_TORRENTS_AUDIOBOOK": hardlink_enabled, - "SUPPORTED_FORMATS": ["epub", "mp3"], - }.get(key, default)) - + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": str(library), + "FILE_ORGANIZATION": "organize", + "HARDLINK_TORRENTS": hardlink_enabled, + "HARDLINK_TORRENTS_AUDIOBOOK": hardlink_enabled, + "SUPPORTED_FORMATS": ["epub", "mp3"], + }.get(key, default) + ) result = _post_process_download( temp_file=temp_file, @@ -62,13 +67,13 @@ class TestStageFile: def test_copy_mode_preserves_original(self, tmp_path): """copy=True preserves original file (for torrent seeding).""" - from shelfmark.download.staging import stage_file, get_staging_dir + from shelfmark.download.staging import stage_file source = tmp_path / "downloads" / "book.epub" source.parent.mkdir() source.write_bytes(b"content") - with patch('shelfmark.config.env.TMP_DIR', tmp_path / "staging"): + with patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"): staged = stage_file(source, "task123", copy=True) assert staged.exists() @@ -83,7 +88,7 @@ class TestStageFile: source.parent.mkdir() source.write_bytes(b"content") - with patch('shelfmark.config.env.TMP_DIR', tmp_path / "staging"): + with patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"): staged = stage_file(source, "task123", copy=False) assert staged.exists() @@ -101,7 +106,7 @@ class TestStageFile: source.parent.mkdir() source.write_bytes(b"new content") - with patch('shelfmark.config.env.TMP_DIR', staging): + with patch("shelfmark.config.env.TMP_DIR", staging): staged = stage_file(source, "task123", copy=True) assert staged.name == "book_1.epub" @@ -151,12 +156,12 @@ class TestSameFilesystem: def test_permission_error_returns_false(self, tmp_path): """Returns False when permission denied (safe fallback).""" - with patch('os.stat', side_effect=PermissionError("denied")): + with patch("os.stat", side_effect=PermissionError("denied")): assert same_filesystem(tmp_path, tmp_path) is False def test_oserror_returns_false(self, tmp_path): """Returns False on OS errors (safe fallback).""" - with patch('os.stat', side_effect=OSError("error")): + with patch("os.stat", side_effect=OSError("error")): assert same_filesystem(tmp_path, tmp_path) is False @@ -332,7 +337,6 @@ class TestAtomicMove: def test_cross_filesystem_fallback(self): """Falls back to copy when cross-filesystem.""" from shelfmark.download.fs import atomic_move as _atomic_move - import errno with tempfile.TemporaryDirectory() as dir1, tempfile.TemporaryDirectory() as dir2: source = Path(dir1) / "source.txt" @@ -348,9 +352,10 @@ class TestAtomicMove: def test_cross_filesystem_permission_fallback(self, tmp_path, monkeypatch): """Falls back to copy when cross-filesystem move hits permission error.""" - from shelfmark.download.fs import atomic_move as _atomic_move import errno + from shelfmark.download.fs import atomic_move as _atomic_move + source = tmp_path / "source.txt" source.write_text("content") dest = tmp_path / "dest.txt" @@ -365,8 +370,14 @@ class TestAtomicMove: monkeypatch.setattr(os, "rename", _raise_exdev) - with patch("shelfmark.download.fs.shutil.copy2", side_effect=PermissionError("no")) as mock_copy, \ - patch("shelfmark.download.fs._perform_nfs_fallback", side_effect=_fallback_copy) as mock_fallback: + with ( + patch( + "shelfmark.download.fs.shutil.copy2", side_effect=PermissionError("no") + ) as mock_copy, + patch( + "shelfmark.download.fs._perform_nfs_fallback", side_effect=_fallback_copy + ) as mock_fallback, + ): result = _atomic_move(source, dest) assert result == dest @@ -375,11 +386,14 @@ class TestAtomicMove: assert mock_copy.called assert mock_fallback.called - def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(self, tmp_path, monkeypatch): + def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent( + self, tmp_path, monkeypatch + ): """Completes EXDEV fallback move when copy2 metadata fails with ENOENT.""" - from shelfmark.download.fs import atomic_move as _atomic_move import errno + from shelfmark.download.fs import atomic_move as _atomic_move + source = tmp_path / "source.txt" source.write_text("content") dest = tmp_path / "dest.txt" @@ -407,21 +421,6 @@ class TestAtomicMove: class TestHardlinkWithLibraryMode: """Tests for hardlinking in library mode context.""" - @pytest.fixture - def mock_config(self): - """Mock config for library mode.""" - with patch('shelfmark.core.config.config') as mock: - mock.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "LIBRARY_PATH": None, - "LIBRARY_PATH_AUDIOBOOK": None, - "LIBRARY_TEMPLATE": "{Author}/{Title}", - "LIBRARY_TEMPLATE_AUDIOBOOK": "{Author}/{Title}", - "TORRENT_HARDLINK": True, - "PROCESSING_MODE": "library", - "PROCESSING_MODE_AUDIOBOOK": "library", - }.get(key, default)) - yield mock - @pytest.fixture def sample_task(self): """Create a sample DownloadTask for testing.""" @@ -451,7 +450,7 @@ class TestHardlinkWithLibraryMode: status_cb = MagicMock() - with patch('shelfmark.config.env.TMP_DIR', temp_file.parent): + with patch("shelfmark.config.env.TMP_DIR", temp_file.parent): result = transfer_file_to_library( source_path=source, library_base=str(library), @@ -528,8 +527,12 @@ class TestHardlinkWithLibraryMode: sample_task.content_type = "audiobook" status_cb = MagicMock() - with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]), \ - patch('shelfmark.config.env.TMP_DIR', temp_dir.parent): + with ( + patch( + "shelfmark.download.postprocess.scan.get_supported_formats", return_value=["mp3"] + ), + patch("shelfmark.config.env.TMP_DIR", temp_dir.parent), + ): result = transfer_directory_to_library( source_dir=source_dir, library_base=str(library), @@ -576,8 +579,12 @@ class TestHardlinkWithLibraryMode: sample_task.content_type = "audiobook" status_cb = MagicMock() - with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]), \ - patch('shelfmark.config.env.TMP_DIR', source_dir.parent): + with ( + patch( + "shelfmark.download.postprocess.scan.get_supported_formats", return_value=["mp3"] + ), + patch("shelfmark.config.env.TMP_DIR", source_dir.parent), + ): result = transfer_directory_to_library( source_dir=source_dir, library_base=str(library), @@ -613,7 +620,9 @@ class TestHardlinkWithLibraryMode: status_cb = MagicMock() - with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["epub"]): + with patch( + "shelfmark.download.postprocess.scan.get_supported_formats", return_value=["epub"] + ): result = transfer_directory_to_library( source_dir=source_dir, library_base=str(library), @@ -661,8 +670,6 @@ class TestHardlinkDecisionLogic: # Task has original_download_path (torrent scenario) sample_task.original_download_path = str(source) - status_cb = MagicMock() - result, _ = _run_organize_post_process( temp_file=staged, task=sample_task, @@ -688,8 +695,6 @@ class TestHardlinkDecisionLogic: sample_task.original_download_path = str(source) - status_cb = MagicMock() - result, _ = _run_organize_post_process( temp_file=staged, task=sample_task, @@ -768,10 +773,10 @@ class TestHardlinkInodeVerification: # Initial link count is 1 assert os.stat(source).st_nlink == 1 - dest1 = _atomic_hardlink(source, tmp_path / "link1.txt") + _atomic_hardlink(source, tmp_path / "link1.txt") assert os.stat(source).st_nlink == 2 - dest2 = _atomic_hardlink(source, tmp_path / "link2.txt") + _atomic_hardlink(source, tmp_path / "link2.txt") assert os.stat(source).st_nlink == 3 @@ -898,7 +903,9 @@ class TestTorrentOptimization: sample_task.content_type = "audiobook" status_cb = MagicMock() - with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]): + with patch( + "shelfmark.download.postprocess.scan.get_supported_formats", return_value=["mp3"] + ): result = transfer_directory_to_library( source_dir=torrent_dir, library_base=str(library), @@ -933,23 +940,25 @@ class TestTorrentSourceCleanupProtection: def _make_config_mock(self, library_path: str, hardlink: bool = True): """Create config mock for library/organize mode with hardlinking.""" - return MagicMock(side_effect=lambda key, default=None, **_kwargs: { - # Destination paths (what _get_final_destination uses) - "DESTINATION": library_path, - "DESTINATION_AUDIOBOOK": library_path, - # Templates (what _get_template uses) - "TEMPLATE_ORGANIZE": "{Author}/{Title}", - "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", - # File organization mode - "FILE_ORGANIZATION": "organize", - "FILE_ORGANIZATION_AUDIOBOOK": "organize", - # Hardlink toggle - "HARDLINK_TORRENTS": hardlink, - "HARDLINK_TORRENTS_AUDIOBOOK": hardlink, - # Supported formats - "SUPPORTED_FORMATS": ["epub", "mobi", "cbz", "cbr", "azw3", "fb2", "djvu", "pdf"], - "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3", "m4a", "m4b", "flac"], - }.get(key, default)) + return MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + # Destination paths (what _get_final_destination uses) + "DESTINATION": library_path, + "DESTINATION_AUDIOBOOK": library_path, + # Templates (what _get_template uses) + "TEMPLATE_ORGANIZE": "{Author}/{Title}", + "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + # File organization mode + "FILE_ORGANIZATION": "organize", + "FILE_ORGANIZATION_AUDIOBOOK": "organize", + # Hardlink toggle + "HARDLINK_TORRENTS": hardlink, + "HARDLINK_TORRENTS_AUDIOBOOK": hardlink, + # Supported formats + "SUPPORTED_FORMATS": ["epub", "mobi", "cbz", "cbr", "azw3", "fb2", "djvu", "pdf"], + "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3", "m4a", "m4b", "flac"], + }.get(key, default) + ) # ==================== EPUB EBOOK TESTS ==================== @@ -962,8 +971,10 @@ class TestTorrentSourceCleanupProtection: Library mode hardlinks to /library/Brandon Sanderson/The Way of Kings.epub Original MUST remain for seeding. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) # Simulate qBittorrent's download location downloads = tmp_path / "downloads" / "complete" @@ -988,7 +999,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() # Patch config used by postprocess pipeline - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(torrent_file, task, Event(), status_cb) @@ -1010,8 +1021,10 @@ class TestTorrentSourceCleanupProtection: Same flow as epub but with .mobi format. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" / "complete" downloads.mkdir(parents=True) @@ -1033,7 +1046,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(torrent_file, task, Event(), status_cb) @@ -1054,8 +1067,10 @@ class TestTorrentSourceCleanupProtection: Library mode hardlinks all mp3s to /library/Andy Weir/Project Hail Mary - 01.mp3, etc. ALL original files must remain for seeding. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) # Simulate torrent audiobook structure downloads = tmp_path / "downloads" / "complete" @@ -1089,7 +1104,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(torrent_dir, task, Event(), status_cb) @@ -1112,8 +1127,10 @@ class TestTorrentSourceCleanupProtection: Simulates: User downloads comic via torrent. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" / "complete" downloads.mkdir(parents=True) @@ -1136,7 +1153,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(torrent_file, task, Event(), status_cb) @@ -1152,8 +1169,10 @@ class TestTorrentSourceCleanupProtection: For external usenet downloads, Shelfmark treats the client path as read-only and avoids deleting anything itself. Client-side cleanup is handled separately. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" / "complete" downloads.mkdir(parents=True) @@ -1175,7 +1194,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(usenet_file, task, Event(), status_cb) @@ -1190,8 +1209,10 @@ class TestTorrentSourceCleanupProtection: Simulates: User downloads directly from Anna's Archive. No torrent client involved, no seeding needed. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" staging.mkdir() @@ -1213,7 +1234,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: mock_orch.get = self._make_config_mock(str(library), hardlink=True) mock_orch.CUSTOM_SCRIPT = None result = _post_process_download(staged_file, task, Event(), status_cb) @@ -1229,8 +1250,10 @@ class TestTorrentSourceCleanupProtection: When user disables hardlinking but downloads via torrent, the file must still be preserved for seeding (via copy). """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" / "complete" downloads.mkdir(parents=True) @@ -1252,7 +1275,7 @@ class TestTorrentSourceCleanupProtection: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_orch: + with patch("shelfmark.core.config.config") as mock_orch: # Hardlink DISABLED mock_orch.get = self._make_config_mock(str(library), hardlink=False) mock_orch.CUSTOM_SCRIPT = None @@ -1273,8 +1296,8 @@ class TestTorrentSourceCleanupProtection: When torrent is on different filesystem than library, hardlink fails and should fall back to copy (not move). """ - from shelfmark.download.postprocess.pipeline import transfer_file_to_library from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.pipeline import transfer_file_to_library # Simulate by directly calling transfer_file_to_library with use_hardlink=False # (this is what happens after same_filesystem check fails) @@ -1315,8 +1338,8 @@ class TestTorrentSourceCleanupProtection: def testis_torrent_source_detection(self, tmp_path): """Unit test: is_torrent_source correctly identifies torrent paths.""" - from shelfmark.download.postprocess.pipeline import is_torrent_source from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.pipeline import is_torrent_source torrent_path = tmp_path / "downloads" / "book.epub" torrent_path.parent.mkdir() @@ -1348,9 +1371,8 @@ class TestTorrentSourceCleanupProtection: def testis_torrent_source_falls_back_to_normalized_paths(self, tmp_path, monkeypatch): """If resolve() fails, path comparison should still fall back safely.""" import shelfmark.download.postprocess.transfer as transfer_module - - from shelfmark.download.postprocess.pipeline import is_torrent_source from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.pipeline import is_torrent_source torrent_path = tmp_path / "downloads" / "book.epub" fallback_path = tmp_path / "downloads" / ".." / "downloads" / "book.epub" @@ -1379,8 +1401,8 @@ class TestEdgeCases: def test_empty_directory_returns_none(self, tmp_path): """Empty source directory returns None.""" - from shelfmark.download.postprocess.pipeline import transfer_directory_to_library from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.pipeline import transfer_directory_to_library task = DownloadTask( task_id="test", @@ -1398,7 +1420,9 @@ class TestEdgeCases: status_cb = MagicMock() - with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["epub"]): + with patch( + "shelfmark.download.postprocess.scan.get_supported_formats", return_value=["epub"] + ): result = transfer_directory_to_library( source_dir=source_dir, library_base=str(library), @@ -1414,8 +1438,10 @@ class TestEdgeCases: def test_nonexistent_source_for_hardlink(self, tmp_path): """Missing source file prevents hardlink creation.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) task = DownloadTask( task_id="test", @@ -1435,13 +1461,15 @@ class TestEdgeCases: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_config: - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": str(library), - "TEMPLATE_ORGANIZE": "{Title}", - "FILE_ORGANIZATION": "organize", - "HARDLINK_TORRENTS": True, - }.get(key, default)) + with patch("shelfmark.core.config.config") as mock_config: + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": str(library), + "TEMPLATE_ORGANIZE": "{Title}", + "FILE_ORGANIZATION": "organize", + "HARDLINK_TORRENTS": True, + }.get(key, default) + ) mock_config.CUSTOM_SCRIPT = None result = _post_process_download(staged, task, Event(), status_cb) @@ -1452,8 +1480,10 @@ class TestEdgeCases: def test_permission_denied_library_path(self, tmp_path): """Handles permission denied on library path.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download from shelfmark.core.models import DownloadTask, SearchMode + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) task = DownloadTask( task_id="test", @@ -1470,12 +1500,14 @@ class TestEdgeCases: status_cb = MagicMock() - with patch('shelfmark.core.config.config') as mock_config: - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: { - "DESTINATION": "/nonexistent/protected/path", - "TEMPLATE_ORGANIZE": "{Title}", - "FILE_ORGANIZATION": "organize", - }.get(key, default)) + with patch("shelfmark.core.config.config") as mock_config: + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: { + "DESTINATION": "/nonexistent/protected/path", + "TEMPLATE_ORGANIZE": "{Title}", + "FILE_ORGANIZATION": "organize", + }.get(key, default) + ) result = _post_process_download(staged, task, Event(), status_cb) diff --git a/tests/core/test_library_processing.py b/tests/core/test_library_processing.py deleted file mode 100644 index 422625e..0000000 --- a/tests/core/test_library_processing.py +++ /dev/null @@ -1,2321 +0,0 @@ -""" -Tests for library processing mode - ebook and audiobook routing with different configurations. - -These tests verify that the orchestrator correctly routes content based on: -- PROCESSING_MODE (books): ingest vs library -- PROCESSING_MODE_AUDIOBOOK: ingest vs library -- LIBRARY_PATH / LIBRARY_PATH_AUDIOBOOK paths -- LIBRARY_TEMPLATE / LIBRARY_TEMPLATE_AUDIOBOOK templates -- INGEST_DIR / INGEST_DIR_AUDIOBOOK directories -""" - -import pytest -import tempfile -import shutil -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from shelfmark.core.models import DownloadTask, SearchMode -from shelfmark.core.naming import build_library_path, assign_part_numbers -from shelfmark.core.utils import is_audiobook - - -class MockConfig: - """Mock config for testing with configurable values.""" - - def __init__(self, **kwargs): - self._values = { - # Default values - "PROCESSING_MODE": "ingest", - "PROCESSING_MODE_AUDIOBOOK": "ingest", - "LIBRARY_PATH": "", - "LIBRARY_PATH_AUDIOBOOK": "", - "LIBRARY_TEMPLATE": "{Author}/{Title}", - "LIBRARY_TEMPLATE_AUDIOBOOK": "{Author}/{Title}", - "INGEST_DIR_AUDIOBOOK": "", - "TORRENT_HARDLINK": True, - "USE_BOOK_TITLE": True, - "SUPPORTED_FORMATS": ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], - "SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"], - } - self._values.update(kwargs) - - def get(self, key, default=None): - return self._values.get(key, default) - - def __getattr__(self, name): - if name.startswith('_'): - raise AttributeError(name) - return self._values.get(name) - - -class TestConfigurationScenarios: - """Test different configuration combinations for books and audiobooks.""" - - def test_both_ingest_mode_default(self): - """Default config: both books and audiobooks use ingest mode.""" - config = MockConfig() - - assert config.get("PROCESSING_MODE") == "ingest" - assert config.get("PROCESSING_MODE_AUDIOBOOK") == "ingest" - - def test_books_library_audiobooks_ingest(self): - """Books use library mode, audiobooks use ingest mode.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/books", - LIBRARY_TEMPLATE="{Author}/{Series/}{Title}", - PROCESSING_MODE_AUDIOBOOK="ingest", - INGEST_DIR_AUDIOBOOK="/audiobooks/ingest", - ) - - assert config.get("PROCESSING_MODE") == "library" - assert config.get("LIBRARY_PATH") == "/books" - assert config.get("PROCESSING_MODE_AUDIOBOOK") == "ingest" - assert config.get("INGEST_DIR_AUDIOBOOK") == "/audiobooks/ingest" - - def test_books_ingest_audiobooks_library(self): - """Books use ingest mode, audiobooks use library mode.""" - config = MockConfig( - PROCESSING_MODE="ingest", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="/audiobooks", - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title} - Part {PartNumber}", - ) - - assert config.get("PROCESSING_MODE") == "ingest" - assert config.get("PROCESSING_MODE_AUDIOBOOK") == "library" - assert config.get("LIBRARY_PATH_AUDIOBOOK") == "/audiobooks" - - def test_both_library_different_paths(self): - """Both books and audiobooks in library mode with different paths.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/media/books", - LIBRARY_TEMPLATE="{Author}/{Title} ({Year})", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="/media/audiobooks", - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", - ) - - assert config.get("LIBRARY_PATH") == "/media/books" - assert config.get("LIBRARY_PATH_AUDIOBOOK") == "/media/audiobooks" - assert config.get("LIBRARY_TEMPLATE") == "{Author}/{Title} ({Year})" - assert config.get("LIBRARY_TEMPLATE_AUDIOBOOK") == "{Author}/{Series/}{Title}" - - -class TestContentTypeDetection: - """Test content type detection and routing logic.""" - - def test_detect_audiobook_content_type(self): - """Verify audiobook detection from content_type field.""" - task = DownloadTask( - task_id="test-1", - source="prowlarr", - title="The Way of Kings", - author="Brandon Sanderson", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - assert is_audiobook(task.content_type) - - def test_detect_book_content_type(self): - """Verify ebook detection from content_type field.""" - task = DownloadTask( - task_id="test-2", - source="direct_download", - title="Dune", - author="Frank Herbert", - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - assert not is_audiobook(task.content_type) - - def test_empty_content_type_defaults_to_book(self): - """Empty content_type should be treated as a book.""" - task = DownloadTask( - task_id="test-3", - source="prowlarr", - title="Unknown Book", - content_type=None, - search_mode=SearchMode.UNIVERSAL, - ) - - assert not is_audiobook(task.content_type) - - -class TestLibraryPathBuilding: - """Test library path construction for different content types.""" - - @pytest.fixture - def temp_dirs(self): - """Create temporary directories for testing.""" - books_dir = tempfile.mkdtemp(prefix="test_books_") - audiobooks_dir = tempfile.mkdtemp(prefix="test_audiobooks_") - ingest_dir = tempfile.mkdtemp(prefix="test_ingest_") - - yield { - "books": Path(books_dir), - "audiobooks": Path(audiobooks_dir), - "ingest": Path(ingest_dir), - } - - shutil.rmtree(books_dir, ignore_errors=True) - shutil.rmtree(audiobooks_dir, ignore_errors=True) - shutil.rmtree(ingest_dir, ignore_errors=True) - - def test_book_library_path_simple(self, temp_dirs): - """Test simple book library path.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Title": "Mistborn", - } - - path = build_library_path( - str(temp_dirs["books"]), - template, - metadata, - extension="epub" - ) - - expected = (temp_dirs["books"] / "Brandon Sanderson" / "Mistborn.epub").resolve() - assert path == expected - - def test_audiobook_library_path_with_part_number(self, temp_dirs): - """Test audiobook library path with part number.""" - template = "{Author}/{Title} - Part {PartNumber}" - metadata = { - "Author": "Brandon Sanderson", - "Title": "The Way of Kings", - "PartNumber": "01", - } - - path = build_library_path( - str(temp_dirs["audiobooks"]), - template, - metadata, - extension="mp3" - ) - - expected = (temp_dirs["audiobooks"] / "Brandon Sanderson" / "The Way of Kings - Part 01.mp3").resolve() - assert path == expected - - def test_book_with_series_folder(self, temp_dirs): - """Test book with series creating nested folder.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "Title": "The Way of Kings", - } - - path = build_library_path( - str(temp_dirs["books"]), - template, - metadata, - extension="epub" - ) - - expected = (temp_dirs["books"] / "Brandon Sanderson" / "Stormlight Archive" / "The Way of Kings.epub").resolve() - assert path == expected - - def test_audiobook_with_series_and_parts(self, temp_dirs): - """Test audiobook with series folder and part numbers.""" - template = "{Author}/{Series/}{Title} - Part {PartNumber}" - - base_metadata = { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "Title": "The Way of Kings", - } - - # Build paths for multiple parts - paths = [] - for part_num in ["01", "02", "03"]: - metadata = {**base_metadata, "PartNumber": part_num} - path = build_library_path( - str(temp_dirs["audiobooks"]), - template, - metadata, - extension="mp3" - ) - paths.append(path) - - # All paths should be in the same directory - assert all(p.parent == paths[0].parent for p in paths) - - # Check the directory structure - assert "Stormlight Archive" in str(paths[0]) - assert "Part 01" in str(paths[0]) - assert "Part 02" in str(paths[1]) - assert "Part 03" in str(paths[2]) - - def test_different_templates_same_metadata(self, temp_dirs): - """Same book metadata produces different paths with different templates.""" - metadata = { - "Author": "Frank Herbert", - "Title": "Dune", - "Year": 1965, - "Series": "Dune Chronicles", - "SeriesPosition": 1, - } - - # Book template (simple) - book_path = build_library_path( - str(temp_dirs["books"]), - "{Author}/{Title}", - metadata, - extension="epub" - ) - - # Audiobook template (more elaborate) - audiobook_path = build_library_path( - str(temp_dirs["audiobooks"]), - "{Author}/{Series/}{SeriesPosition - }{Title}", - metadata, - extension="m4b" - ) - - # Verify different structures - assert book_path.parent.name == "Frank Herbert" - assert audiobook_path.parent.parent.name == "Frank Herbert" - assert "Dune Chronicles" in str(audiobook_path) - assert "1 - Dune" in str(audiobook_path) - - -class TestMixedModeProcessing: - """Test scenarios with different processing modes for books vs audiobooks.""" - - @pytest.fixture - def temp_dirs(self): - """Create temporary directories for testing.""" - books_lib = tempfile.mkdtemp(prefix="test_books_lib_") - audiobooks_lib = tempfile.mkdtemp(prefix="test_audiobooks_lib_") - books_ingest = tempfile.mkdtemp(prefix="test_books_ingest_") - audiobooks_ingest = tempfile.mkdtemp(prefix="test_audiobooks_ingest_") - - yield { - "books_lib": Path(books_lib), - "audiobooks_lib": Path(audiobooks_lib), - "books_ingest": Path(books_ingest), - "audiobooks_ingest": Path(audiobooks_ingest), - } - - for d in [books_lib, audiobooks_lib, books_ingest, audiobooks_ingest]: - shutil.rmtree(d, ignore_errors=True) - - def test_books_library_audiobooks_ingest_routing(self, temp_dirs): - """Books to library, audiobooks to ingest - verify path selection.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH=str(temp_dirs["books_lib"]), - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="ingest", - INGEST_DIR_AUDIOBOOK=str(temp_dirs["audiobooks_ingest"]), - ) - - # Create book task - book_task = DownloadTask( - task_id="book-1", - source="prowlarr", - title="Dune", - author="Frank Herbert", - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - # Create audiobook task - audiobook_task = DownloadTask( - task_id="audiobook-1", - source="prowlarr", - title="Dune", - author="Frank Herbert", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - # Determine paths based on content type - is_audiobook_book = "audiobook" in (book_task.content_type or "").lower() - is_audiobook_audio = "audiobook" in (audiobook_task.content_type or "").lower() - - assert not is_audiobook_book - assert is_audiobook_audio - - # Simulate path selection - if is_audiobook_book: - book_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - else: - book_processing = config.get("PROCESSING_MODE", "ingest") - - if is_audiobook_audio: - audio_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - else: - audio_processing = config.get("PROCESSING_MODE", "ingest") - - assert book_processing == "library" - assert audio_processing == "ingest" - - def test_books_ingest_audiobooks_library_routing(self, temp_dirs): - """Books to ingest, audiobooks to library - verify path selection.""" - config = MockConfig( - PROCESSING_MODE="ingest", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK=str(temp_dirs["audiobooks_lib"]), - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", - ) - - # Create tasks - book_task = DownloadTask( - task_id="book-2", - source="prowlarr", - title="Project Hail Mary", - author="Andy Weir", - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - audiobook_task = DownloadTask( - task_id="audiobook-2", - source="prowlarr", - title="Project Hail Mary", - author="Andy Weir", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - # Determine processing modes - is_audiobook_book = "audiobook" in (book_task.content_type or "").lower() - is_audiobook_audio = "audiobook" in (audiobook_task.content_type or "").lower() - - if is_audiobook_book: - book_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - else: - book_processing = config.get("PROCESSING_MODE", "ingest") - - if is_audiobook_audio: - audio_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - else: - audio_processing = config.get("PROCESSING_MODE", "ingest") - - assert book_processing == "ingest" - assert audio_processing == "library" - - # Build audiobook library path - audiobook_path = build_library_path( - config.get("LIBRARY_PATH_AUDIOBOOK"), - config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), - {"Author": audiobook_task.author, "Title": audiobook_task.title}, - extension="m4b" - ) - - assert "Andy Weir" in str(audiobook_path) - assert "Project Hail Mary" in str(audiobook_path) - - -class TestAudiobookPartNumberAssignment: - """Test sequential part number assignment for multi-file audiobooks. - - Uses Readarr's approach: natural sort files then assign sequential numbers. - """ - - def test_assign_part_numbers_sorted(self): - """Files should be naturally sorted and assigned sequential numbers.""" - files = [ - Path("The Way of Kings - Part 03.mp3"), - Path("The Way of Kings - Part 01.mp3"), - Path("The Way of Kings - Part 02.mp3"), - ] - result = assign_part_numbers(files) - - assert result[0] == (Path("The Way of Kings - Part 01.mp3"), "01") - assert result[1] == (Path("The Way of Kings - Part 02.mp3"), "02") - assert result[2] == (Path("The Way of Kings - Part 03.mp3"), "03") - - def test_natural_sort_handles_double_digits(self): - """Numbers sort naturally (2 before 10).""" - files = [ - Path("Track 10.mp3"), - Path("Track 2.mp3"), - Path("Track 1.mp3"), - ] - result = assign_part_numbers(files) - - assert result[0][0].name == "Track 1.mp3" - assert result[1][0].name == "Track 2.mp3" - assert result[2][0].name == "Track 10.mp3" - - def test_problematic_titles_no_false_positives(self): - """Titles with numbers (like Fahrenheit 451) don't cause issues.""" - files = [ - Path("Fahrenheit 451 - Part 2.mp3"), - Path("Fahrenheit 451 - Part 1.mp3"), - ] - result = assign_part_numbers(files) - - # Files sorted correctly, get sequential numbers - assert result[0] == (Path("Fahrenheit 451 - Part 1.mp3"), "01") - assert result[1] == (Path("Fahrenheit 451 - Part 2.mp3"), "02") - - def test_part_number_in_template(self): - """Test PartNumber token in audiobook template.""" - template = "{Author}/{Title} - Part {PartNumber}" - metadata = { - "Author": "Brandon Sanderson", - "Title": "Oathbringer", - "PartNumber": "01", - } - - path = build_library_path("/audiobooks", template, metadata, extension="mp3") - - assert "Part 01" in str(path) - assert path.name == "Oathbringer - Part 01.mp3" - - def test_conditional_part_number(self): - """Test conditional part number inclusion.""" - template = "{Author}/{Title}{ - Part }{PartNumber}" - - # With part number - with_part = build_library_path( - "/audiobooks", - template, - {"Author": "Author", "Title": "Book", "PartNumber": "01"}, - extension="mp3" - ) - - # Without part number (single file audiobook) - without_part = build_library_path( - "/audiobooks", - template, - {"Author": "Author", "Title": "Book", "PartNumber": None}, - extension="m4b" - ) - - # The conditional suffix only appears when PartNumber has a value - # Note: The template { - Part } includes the literal text, and {PartNumber} - # is separate, so we need to adjust expectations - assert "Book.m4b" in str(without_part) or "Book - Part.m4b" not in str(without_part) - - -class TestFilesystemOperations: - """Test actual file operations for library mode.""" - - @pytest.fixture - def temp_setup(self): - """Create temp directories with test files.""" - staging = tempfile.mkdtemp(prefix="test_staging_") - books_lib = tempfile.mkdtemp(prefix="test_books_") - audiobooks_lib = tempfile.mkdtemp(prefix="test_audiobooks_") - - # Create a test epub file - epub_file = Path(staging) / "test_book.epub" - epub_file.write_text("fake epub content") - - # Create test mp3 files (multi-part audiobook) - for i in range(3): - mp3_file = Path(staging) / f"Test Audiobook - Part 0{i+1}.mp3" - mp3_file.write_text(f"fake mp3 content part {i+1}") - - yield { - "staging": Path(staging), - "books_lib": Path(books_lib), - "audiobooks_lib": Path(audiobooks_lib), - "epub_file": epub_file, - } - - shutil.rmtree(staging, ignore_errors=True) - shutil.rmtree(books_lib, ignore_errors=True) - shutil.rmtree(audiobooks_lib, ignore_errors=True) - - def test_move_book_to_library(self, temp_setup): - """Test moving a book file to library structure.""" - metadata = { - "Author": "Brandon Sanderson", - "Title": "The Final Empire", - "Series": "Mistborn", - } - template = "{Author}/{Series/}{Title}" - - dest_path = build_library_path( - str(temp_setup["books_lib"]), - template, - metadata, - extension="epub" - ) - - # Create the directory structure - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Move the file - shutil.move(str(temp_setup["epub_file"]), str(dest_path)) - - # Verify - assert dest_path.exists() - assert dest_path.name == "The Final Empire.epub" - assert "Mistborn" in str(dest_path.parent) - assert "Brandon Sanderson" in str(dest_path) - - def test_move_multipart_audiobook_to_library(self, temp_setup): - """Test moving multi-part audiobook to library structure.""" - metadata = { - "Author": "Brandon Sanderson", - "Title": "Words of Radiance", - "Series": "Stormlight Archive", - } - template = "{Author}/{Series/}{Title} - Part {PartNumber}" - - # Get all mp3 files from staging - mp3_files = list(temp_setup["staging"].glob("*.mp3")) - assert len(mp3_files) == 3 - - # Use assign_part_numbers for natural sort + sequential numbering - files_with_parts = assign_part_numbers(mp3_files) - - moved_files = [] - for mp3_file, part_num in files_with_parts: - file_metadata = {**metadata, "PartNumber": part_num} - - dest_path = build_library_path( - str(temp_setup["audiobooks_lib"]), - template, - file_metadata, - extension="mp3" - ) - - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(mp3_file), str(dest_path)) - moved_files.append(dest_path) - - # Verify all files moved - assert all(f.exists() for f in moved_files) - - # All should be in the same parent directory - assert len(set(f.parent for f in moved_files)) == 1 - - # Check naming - files are sorted then assigned sequential numbers - assert "Part 01" in str(moved_files[0]) - assert "Part 02" in str(moved_files[1]) - assert "Part 03" in str(moved_files[2]) - - -class TestFallbackBehavior: - """Test fallback behavior when library mode is misconfigured.""" - - def test_library_mode_no_path_fallback(self): - """Library mode without path should fall back to ingest.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="", # Empty path - LIBRARY_TEMPLATE="{Author}/{Title}", - ) - - # Check the condition that triggers fallback - library_path = config.get("LIBRARY_PATH") - should_fallback = not library_path - - assert should_fallback - - def test_audiobook_library_mode_no_path_fallback(self): - """Audiobook library mode without path should fall back to ingest.""" - config = MockConfig( - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="", # Empty path - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", - ) - - library_path = config.get("LIBRARY_PATH_AUDIOBOOK") - should_fallback = not library_path - - assert should_fallback - - def test_audiobook_library_path_fallback_to_book_path(self): - """Audiobook should fall back to book library path if audiobook path not set.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/books", - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="", # Empty - should fall back - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", - ) - - # Simulate the fallback logic from orchestrator - audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") - - assert audiobook_path == "/books" - - -class TestDirectModeBypass: - """Test that Direct mode bypasses library processing.""" - - def test_direct_mode_ignores_library_settings(self): - """Direct mode should use ingest regardless of library settings.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/books", - LIBRARY_TEMPLATE="{Author}/{Title}", - ) - - task = DownloadTask( - task_id="direct-1", - source="direct_download", - title="Test Book", - content_type="book (fiction)", - search_mode=SearchMode.DIRECT, # Direct mode - ) - - # In Direct mode, library settings should be ignored - is_universal = task.search_mode == SearchMode.UNIVERSAL - - # The orchestrator only applies library mode for Universal - assert not is_universal - # Therefore library_mode check would return False in orchestrator - - -class TestSearchModeValidation: - """Test search mode validation in download tasks.""" - - def test_universal_mode_enables_library(self): - """Universal mode should enable library processing.""" - task = DownloadTask( - task_id="universal-1", - source="prowlarr", - title="Test Book", - search_mode=SearchMode.UNIVERSAL, - ) - - is_universal = task.search_mode == SearchMode.UNIVERSAL - assert is_universal - - def test_direct_mode_disables_library(self): - """Direct mode should disable library processing.""" - task = DownloadTask( - task_id="direct-2", - source="direct_download", - title="Test Book", - search_mode=SearchMode.DIRECT, - ) - - is_universal = task.search_mode == SearchMode.UNIVERSAL - assert not is_universal - - def test_none_mode_treated_as_direct(self): - """None search mode should be treated as Direct (safe default).""" - task = DownloadTask( - task_id="none-1", - source="direct_download", - title="Test Book", - search_mode=None, - ) - - # None is not Universal, so library mode should not apply - is_universal = task.search_mode == SearchMode.UNIVERSAL - assert not is_universal - - -class TestHardlinkSupport: - """Test hardlink configuration for torrent downloads.""" - - def test_hardlink_enabled_for_torrents(self): - """Hardlinking should be enabled by default for torrents.""" - config = MockConfig() - - assert config.get("TORRENT_HARDLINK", True) is True - - def test_hardlink_disabled(self): - """Hardlinking can be disabled.""" - config = MockConfig(TORRENT_HARDLINK=False) - - assert config.get("TORRENT_HARDLINK", True) is False - - def test_task_with_original_path(self): - """Task should support original_download_path for hardlinking.""" - task = DownloadTask( - task_id="torrent-1", - source="prowlarr", - title="Test Book", - original_download_path="/downloads/completed/test-book.epub", - search_mode=SearchMode.UNIVERSAL, - ) - - assert task.original_download_path is not None - assert "/downloads" in task.original_download_path - - -class TestTemplateFallbacks: - """Test template and path fallback behaviors.""" - - def test_audiobook_template_fallback_to_book_template(self): - """When audiobook template is empty, should fall back to book template.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/books", - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="/audiobooks", - LIBRARY_TEMPLATE_AUDIOBOOK="", # Empty - should fallback - ) - - # Simulate fallback logic - audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE", "{Author}/{Title}") - - assert audiobook_template == "{Author}/{Title}" - - def test_audiobook_both_fallback_to_book(self): - """When both audiobook path and template are empty, fallback to book settings.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/media/books", - LIBRARY_TEMPLATE="{Author}/{Series/}{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="", # Empty - LIBRARY_TEMPLATE_AUDIOBOOK="", # Empty - ) - - # Simulate fallback logic - audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") - audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE") - - assert audiobook_path == "/media/books" - assert audiobook_template == "{Author}/{Series/}{Title}" - - def test_audiobook_custom_template_with_fallback_path(self): - """Custom audiobook template but fallback to book path.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH="/media/all_content", - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK="", # Use book path - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title} - Part {PartNumber}", # Custom - ) - - audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") - audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE") - - # Same path, different template - assert audiobook_path == "/media/all_content" - assert audiobook_template == "{Author}/{Title} - Part {PartNumber}" - - -class TestComplexMetadataScenarios: - """Test complex metadata scenarios with series and part numbers.""" - - @pytest.fixture - def temp_dirs(self): - """Create temporary directories.""" - base = tempfile.mkdtemp(prefix="test_complex_") - dirs = { - "books": Path(base) / "books", - "audiobooks": Path(base) / "audiobooks", - } - for d in dirs.values(): - d.mkdir(parents=True) - yield dirs - shutil.rmtree(base, ignore_errors=True) - - def test_series_with_position_and_part_numbers(self, temp_dirs): - """Test audiobook with series position AND part numbers.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title} - Part {PartNumber}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "SeriesPosition": 2, - "Title": "Words of Radiance", - "PartNumber": "01", - } - - path = build_library_path( - str(temp_dirs["audiobooks"]), - template, - metadata, - extension="mp3" - ) - - # Should produce: Author/Series/2 - Title - Part 01.mp3 - assert "Brandon Sanderson" in str(path) - assert "Stormlight Archive" in str(path) - assert "2 - Words of Radiance - Part 01" in str(path) - - def test_novella_position_format(self, temp_dirs): - """Test novella with fractional series position (e.g., 1.5).""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "SeriesPosition": 2.5, # Novella between books 2 and 3 - "Title": "Edgedancer", - } - - path = build_library_path( - str(temp_dirs["books"]), - template, - metadata, - extension="epub" - ) - - assert "2.5 - Edgedancer" in str(path) - - def test_series_without_position(self, temp_dirs): - """Test series book without position.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Cosmere", - "SeriesPosition": None, # Unknown position - "Title": "Elantris", - } - - path = build_library_path( - str(temp_dirs["books"]), - template, - metadata, - extension="epub" - ) - - # Should omit position: Author/Series/Title.epub - assert "Cosmere" in str(path) - assert "Elantris.epub" in str(path) - assert " - Elantris" not in str(path) # No dangling separator - - def test_standalone_no_series(self, temp_dirs): - """Test standalone book with no series.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Andy Weir", - "Series": None, - "SeriesPosition": None, - "Title": "Project Hail Mary", - } - - path = build_library_path( - str(temp_dirs["books"]), - template, - metadata, - extension="epub" - ) - - # Should be: Author/Title.epub (no series folder) - assert path.parent.name == "Andy Weir" - assert path.name == "Project Hail Mary.epub" - - -class TestConcurrentContentProcessing: - """Test processing multiple content types simultaneously.""" - - @pytest.fixture - def temp_setup(self): - """Create a realistic test environment.""" - base = tempfile.mkdtemp(prefix="test_concurrent_") - dirs = { - "staging": Path(base) / "staging", - "books_lib": Path(base) / "books_lib", - "audiobooks_lib": Path(base) / "audiobooks_lib", - "ingest": Path(base) / "ingest", - } - for d in dirs.values(): - d.mkdir(parents=True) - - # Create test files - (dirs["staging"] / "test.epub").write_text("epub") - (dirs["staging"] / "test.m4b").write_text("m4b") - (dirs["staging"] / "audiobook_part_01.mp3").write_text("mp3-1") - (dirs["staging"] / "audiobook_part_02.mp3").write_text("mp3-2") - - yield dirs - shutil.rmtree(base, ignore_errors=True) - - def test_process_book_and_audiobook_simultaneously(self, temp_setup): - """Process an ebook and audiobook at the same time with different modes.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH=str(temp_setup["books_lib"]), - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="ingest", - INGEST_DIR_AUDIOBOOK=str(temp_setup["ingest"]), - ) - - # Book task (library mode) - book = DownloadTask( - task_id="book-1", - source="prowlarr", - title="Foundation", - author="Isaac Asimov", - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - # Audiobook task (ingest mode) - audiobook = DownloadTask( - task_id="audio-1", - source="prowlarr", - title="Foundation", - author="Isaac Asimov", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - # Determine processing for each - def get_processing_mode(task): - is_audiobook = "audiobook" in (task.content_type or "").lower() - if is_audiobook: - return config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") - return config.get("PROCESSING_MODE", "ingest") - - book_mode = get_processing_mode(book) - audiobook_mode = get_processing_mode(audiobook) - - assert book_mode == "library" - assert audiobook_mode == "ingest" - - # Process book to library - book_path = build_library_path( - config.get("LIBRARY_PATH"), - config.get("LIBRARY_TEMPLATE"), - {"Author": book.author, "Title": book.title}, - extension="epub" - ) - book_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(str(temp_setup["staging"] / "test.epub"), str(book_path)) - - # Process audiobook to ingest - audiobook_dest = Path(config.get("INGEST_DIR_AUDIOBOOK")) / "test.m4b" - shutil.copy(str(temp_setup["staging"] / "test.m4b"), str(audiobook_dest)) - - # Verify both processed correctly - assert book_path.exists() - assert "Isaac Asimov" in str(book_path) - assert audiobook_dest.exists() - assert audiobook_dest.parent == Path(config.get("INGEST_DIR_AUDIOBOOK")) - - def test_same_title_different_formats_different_locations(self, temp_setup): - """Same book as ebook and audiobook going to different locations.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH=str(temp_setup["books_lib"]), - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK=str(temp_setup["audiobooks_lib"]), - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", - ) - - metadata = { - "Author": "Isaac Asimov", - "Title": "Foundation", - } - - # Same book as ebook - book_path = build_library_path( - config.get("LIBRARY_PATH"), - config.get("LIBRARY_TEMPLATE"), - metadata, - extension="epub" - ) - - # Same book as audiobook - audiobook_path = build_library_path( - config.get("LIBRARY_PATH_AUDIOBOOK"), - config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), - metadata, - extension="m4b" - ) - - # Different base paths, same structure - assert str(temp_setup["books_lib"]) in str(book_path) - assert str(temp_setup["audiobooks_lib"]) in str(audiobook_path) - assert book_path.name == "Foundation.epub" - assert audiobook_path.name == "Foundation.m4b" - - -class TestEmptyFieldHandling: - """Test template behavior when fields are empty, None, or missing.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_empty_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - # === SERIES FIELD EMPTY === - - def test_series_folder_not_created_when_series_none(self, temp_dir): - """Series folder should NOT be created when Series is None.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": None, - "Title": "Warbreaker", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should be Author/Title.epub (no series folder) - assert path.parent.name == "Brandon Sanderson" - assert path.name == "Warbreaker.epub" - assert "Series" not in str(path) - - def test_series_folder_not_created_when_series_empty_string(self, temp_dir): - """Series folder should NOT be created when Series is empty string.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "", - "Title": "Warbreaker", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "Brandon Sanderson" - assert path.name == "Warbreaker.epub" - - def test_series_folder_not_created_when_series_whitespace(self, temp_dir): - """Series folder should NOT be created when Series is whitespace.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": " ", - "Title": "Warbreaker", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "Brandon Sanderson" - assert path.name == "Warbreaker.epub" - - def test_series_folder_not_created_when_series_missing(self, temp_dir): - """Series folder should NOT be created when Series key is missing.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Title": "Warbreaker", - # Series key not present - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "Brandon Sanderson" - assert path.name == "Warbreaker.epub" - - def test_series_position_omitted_when_series_empty(self, temp_dir): - """Series position should be omitted when series is empty.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Andy Weir", - "Series": None, - "SeriesPosition": None, - "Title": "Project Hail Mary", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # No series folder, no position prefix - assert path.parent.name == "Andy Weir" - assert path.name == "Project Hail Mary.epub" - assert " - " not in path.name - - def test_series_with_position_but_no_series_name(self, temp_dir): - """When position exists but series name is empty, omit both.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Andy Weir", - "Series": "", # Empty series - "SeriesPosition": 1, # But has position - "Title": "The Martian", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Series folder should NOT be created even though position exists - # Position prefix might still appear (debatable behavior) - assert "The Martian" in path.name - - # === AUTHOR FIELD EMPTY === - - def test_author_empty_falls_back_to_unknown(self, temp_dir): - """When author is empty, should use 'Unknown Author' or skip.""" - template = "{Author}/{Title}" - metadata = { - "Author": None, - "Title": "Mystery Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should still create a valid path - assert path.name == "Mystery Book.epub" - # The parent might be the base dir if Author is omitted entirely - # or might be "Unknown" - depends on implementation - - def test_author_empty_string(self, temp_dir): - """When author is empty string.""" - template = "{Author}/{Title}" - metadata = { - "Author": "", - "Title": "Mystery Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - assert "Mystery Book.epub" in str(path) - - def test_author_whitespace_only(self, temp_dir): - """When author is whitespace only.""" - template = "{Author}/{Title}" - metadata = { - "Author": " ", - "Title": "Mystery Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - assert "Mystery Book.epub" in str(path) - - # === TITLE FIELD EMPTY === - - def test_title_empty_uses_fallback(self, temp_dir): - """When title is empty, path should still be valid.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Test Author", - "Title": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should create some valid path - assert path.suffix == ".epub" - - def test_title_empty_string(self, temp_dir): - """When title is empty string.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Test Author", - "Title": "", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - assert path.suffix == ".epub" - - # === YEAR FIELD EMPTY === - - def test_year_empty_omits_parentheses(self, temp_dir): - """Year empty should not leave dangling parentheses.""" - template = "{Author}/{Title} ({Year})" - metadata = { - "Author": "Test Author", - "Title": "Test Book", - "Year": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should not have empty parentheses - assert "()" not in path.name - assert "( )" not in path.name - - def test_year_zero_handled(self, temp_dir): - """Year of 0 should be treated as missing.""" - template = "{Author}/{Title} ({Year})" - metadata = { - "Author": "Test Author", - "Title": "Test Book", - "Year": 0, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # 0 might be treated as falsy and omitted - # or might appear as "(0)" - depends on implementation - - def test_year_as_string(self, temp_dir): - """Year as string should work.""" - template = "{Author}/{Title} ({Year})" - metadata = { - "Author": "Test Author", - "Title": "Test Book", - "Year": "2024", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "(2024)" in path.name - - # === SUBTITLE FIELD EMPTY === - - def test_subtitle_empty_no_separator(self, temp_dir): - """Empty subtitle should not leave dangling separator.""" - template = "{Author}/{Title}{ - Subtitle}" - metadata = { - "Author": "Test Author", - "Title": "Main Title", - "Subtitle": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "Main Title.epub" - assert " - " not in path.name - - def test_subtitle_empty_string_no_separator(self, temp_dir): - """Empty string subtitle should not leave dangling separator.""" - template = "{Author}/{Title}{ - Subtitle}" - metadata = { - "Author": "Test Author", - "Title": "Main Title", - "Subtitle": "", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert " - " not in path.name # No dangling " - " - - def test_subtitle_with_value(self, temp_dir): - """Subtitle with value should include separator.""" - template = "{Author}/{Title}{ - Subtitle}" - metadata = { - "Author": "Test Author", - "Title": "Main Title", - "Subtitle": "A Subtitle", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Main Title - A Subtitle.epub" == path.name - - # === PART NUMBER FIELD EMPTY === - - def test_part_number_empty_no_part_text(self, temp_dir): - """Empty part number should not show 'Part' text.""" - template = "{Author}/{Title}{ - Part }{PartNumber}" - metadata = { - "Author": "Test Author", - "Title": "Audiobook", - "PartNumber": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - assert "Part" not in path.name - assert path.name == "Audiobook.m4b" - - def test_part_number_zero(self, temp_dir): - """Part number of 0 - might be valid or treated as missing.""" - template = "{Author}/{Title} - Part {PartNumber}" - metadata = { - "Author": "Test Author", - "Title": "Audiobook", - "PartNumber": "0", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - # "0" is a valid part number - assert "Part 0" in path.name or "Part" not in path.name - - # === MULTIPLE EMPTY FIELDS === - - def test_all_optional_fields_empty(self, temp_dir): - """All optional fields empty - only required fields present.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" - metadata = { - "Author": "Test Author", - "Title": "Test Book", - "Series": None, - "SeriesPosition": None, - "Subtitle": None, - "Year": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should just be Author/Title.epub - assert path.parent.name == "Test Author" - assert path.name == "Test Book.epub" - assert "Series" not in str(path) - assert " - " not in path.name - assert "()" not in path.name - - def test_only_title_present(self, temp_dir): - """Only title present, everything else empty.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": None, - "Series": None, - "Title": "Orphan Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Orphan Book.epub" in str(path) - - def test_complex_template_all_empty_except_required(self, temp_dir): - """Complex template with all optional fields empty.""" - # Note: Parentheses around Year are NOT conditional - they always appear - # To make them conditional, use the suffix syntax: {Year )} won't work either - # Best approach: just use {Year} and accept parentheses are always there, or - # use a simpler template - template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle}{ - Part }{PartNumber}" - metadata = { - "Author": "Author Name", - "Title": "Book Title", - "Series": None, - "SeriesPosition": None, - "Subtitle": None, - "Year": None, - "PartNumber": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should be clean: Author Name/Book Title.epub - assert path.name == "Book Title.epub" - assert path.parent.name == "Author Name" - - -class TestFolderCreationEdgeCases: - """Test folder creation with various edge cases.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_folders_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - def test_nested_series_creates_all_folders(self, temp_dir): - """Creating nested folder structure.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Cosmere/Stormlight Archive", # Nested! - "Title": "The Way of Kings", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - # Note: slash in series name might be sanitized to underscore - # or might create actual nested folders - depends on implementation - assert path.parent.exists() or True # Check what actually happens - - def test_author_with_special_chars_in_folder(self, temp_dir): - """Author name with special characters creates valid folder.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Author: The Great?", # Has invalid chars - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - assert path.parent.exists() - # Folder name should be sanitized - assert ":" not in path.parent.name - assert "?" not in path.parent.name - - def test_series_with_special_chars_in_folder(self, temp_dir): - """Series name with special characters creates valid folder.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author", - "Series": "Series: Volume 1?", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - assert path.parent.exists() - - def test_very_long_author_name_truncated(self, temp_dir): - """Very long author name should be truncated for folder.""" - template = "{Author}/{Title}" - metadata = { - "Author": "A" * 300, # 300 char author name - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Folder name should be truncated to filesystem limit - assert len(path.parent.name) <= 255 - - def test_very_long_series_name_truncated(self, temp_dir): - """Very long series name should be truncated for folder.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author", - "Series": "S" * 300, - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # All path components should be valid lengths - - def test_unicode_author_creates_folder(self, temp_dir): - """Unicode author name creates valid folder.""" - template = "{Author}/{Title}" - metadata = { - "Author": "村上春樹", # Haruki Murakami in Japanese - "Title": "Norwegian Wood", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - assert path.parent.exists() - assert "村上春樹" in str(path) - - def test_unicode_series_creates_folder(self, temp_dir): - """Unicode series name creates valid folder.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author", - "Series": "Série Française", # French with accent - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - assert path.parent.exists() - - def test_mixed_empty_and_present_folder_levels(self, temp_dir): - """Some folder levels present, some empty.""" - template = "{Author}/{Series/}{Subseries/}{Title}" - metadata = { - "Author": "Author", - "Series": "Main Series", - "Subseries": None, # Empty middle level - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should skip empty Subseries folder - assert "Main Series" in str(path) - - def test_dots_in_folder_names(self, temp_dir): - """Folder names with dots should work.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Dr. Author Ph.D.", - "Series": "Vol. 1", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - path.parent.mkdir(parents=True, exist_ok=True) - - assert path.parent.exists() - - def test_leading_dots_in_folder_stripped(self, temp_dir): - """Leading dots in folder names might be stripped (hidden files).""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": ".Hidden Author", - "Series": "..Series", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Leading dots might be stripped to avoid hidden folders - # or might be preserved - depends on implementation - - def test_trailing_dots_in_folder_stripped(self, temp_dir): - """Trailing dots in folder names should be stripped (Windows issue).""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author...", - "Series": "Series.", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Trailing dots can cause issues on Windows - - def test_reserved_windows_names_handled(self, temp_dir): - """Reserved Windows names (CON, PRN, etc.) should be handled.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "CON", # Reserved on Windows - "Series": "PRN", # Reserved on Windows - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should handle reserved names somehow - - -class TestConditionalTemplateTokens: - """Test conditional token syntax behavior.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_conditional_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - # === CONDITIONAL PREFIX SYNTAX {prefix Token} === - - def test_conditional_prefix_with_value(self, temp_dir): - """Conditional prefix appears when value present.""" - template = "{Author}/{SeriesPosition - }{Title}" - metadata = { - "Author": "Author", - "SeriesPosition": 1, - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "1 - Book" in path.name - - def test_conditional_prefix_without_value(self, temp_dir): - """Conditional prefix hidden when value empty.""" - template = "{Author}/{SeriesPosition - }{Title}" - metadata = { - "Author": "Author", - "SeriesPosition": None, - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "Book.epub" - assert " - " not in path.name - - # === CONDITIONAL SUFFIX SYNTAX {Token suffix} === - - def test_conditional_suffix_with_value(self, temp_dir): - """Conditional suffix appears when value present.""" - template = "{Author}/{Title}{ - Subtitle}" - metadata = { - "Author": "Author", - "Title": "Main", - "Subtitle": "Sub", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "Main - Sub.epub" - - def test_conditional_suffix_without_value(self, temp_dir): - """Conditional suffix hidden when value empty.""" - template = "{Author}/{Title}{ - Subtitle}" - metadata = { - "Author": "Author", - "Title": "Main", - "Subtitle": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "Main.epub" - - # === FOLDER CONDITIONAL SYNTAX {Token/} === - - def test_folder_conditional_with_value(self, temp_dir): - """Folder created when value present.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author", - "Series": "My Series", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "My Series" - assert path.parent.parent.name == "Author" - - def test_folder_conditional_without_value(self, temp_dir): - """Folder NOT created when value empty.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Author", - "Series": None, - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "Author" - - # === PARENTHETICAL CONDITIONALS === - - def test_year_in_parentheses_with_value(self, temp_dir): - """Year in parentheses shown when present.""" - template = "{Author}/{Title} ({Year})" - metadata = { - "Author": "Author", - "Title": "Book", - "Year": 2024, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "(2024)" in path.name - - def test_year_in_parentheses_without_value(self, temp_dir): - """Year in parentheses - empty parentheses should not appear.""" - template = "{Author}/{Title} ({Year})" - metadata = { - "Author": "Author", - "Title": "Book", - "Year": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should NOT have empty parentheses - assert "()" not in path.name - # But might have " ()" or just "Book.epub" - - # === COMBINED CONDITIONALS === - - def test_multiple_conditionals_all_present(self, temp_dir): - """Multiple conditional tokens, all have values.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" - metadata = { - "Author": "Author", - "Series": "Series", - "SeriesPosition": 1, - "Title": "Title", - "Subtitle": "Subtitle", - "Year": 2024, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Series" in str(path.parent) - assert "1 - Title - Subtitle (2024).epub" == path.name - - def test_multiple_conditionals_none_present(self, temp_dir): - """Multiple conditional tokens, none have values.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" - metadata = { - "Author": "Author", - "Series": None, - "SeriesPosition": None, - "Title": "Title", - "Subtitle": None, - "Year": None, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.parent.name == "Author" - assert path.name == "Title.epub" - - def test_multiple_conditionals_mixed(self, temp_dir): - """Multiple conditional tokens, some present some not.""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" - metadata = { - "Author": "Author", - "Series": "Series", # Present - "SeriesPosition": None, # Missing - "Title": "Title", - "Subtitle": "Subtitle", # Present - "Year": None, # Missing - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Series" in str(path.parent) - assert path.name == "Title - Subtitle.epub" - assert " - Title" not in path.name # No SeriesPosition prefix - - -class TestAudiobookSpecificScenarios: - """Test audiobook-specific scenarios.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_audiobook_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - def test_single_file_audiobook_no_part(self, temp_dir): - """Single file audiobook should not have part number.""" - template = "{Author}/{Title}{ - Part }{PartNumber}" - metadata = { - "Author": "Author", - "Title": "Short Audiobook", - "PartNumber": None, # Single file, no part - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - assert path.name == "Short Audiobook.m4b" - assert "Part" not in path.name - - def test_multi_part_audiobook_consistent_paths(self, temp_dir): - """All parts of audiobook should go to same folder.""" - template = "{Author}/{Series/}{Title} - Part {PartNumber}" - base_metadata = { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "Title": "The Way of Kings", - } - - paths = [] - for part in ["01", "02", "03", "04", "05"]: - metadata = {**base_metadata, "PartNumber": part} - path = build_library_path(str(temp_dir), template, metadata, extension="mp3") - paths.append(path) - - # All parts should be in the same directory - parents = set(p.parent for p in paths) - assert len(parents) == 1 - - # Each part should have correct name - assert "Part 01" in str(paths[0]) - assert "Part 05" in str(paths[4]) - - def test_audiobook_with_series_no_position(self, temp_dir): - """Audiobook in series but position unknown.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Patrick Rothfuss", - "Series": "Kingkiller Chronicle", - "SeriesPosition": None, - "Title": "The Name of the Wind", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - assert "Kingkiller Chronicle" in str(path) - assert path.name == "The Name of the Wind.m4b" - - def test_audiobook_standalone_no_series(self, temp_dir): - """Standalone audiobook with no series.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Andy Weir", - "Series": None, - "Title": "The Martian", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - assert path.parent.name == "Andy Weir" - assert path.name == "The Martian.m4b" - - def test_audiobook_narrator_in_path(self, temp_dir): - """Audiobook with narrator in template (if supported).""" - template = "{Author}/{Title} (narrated by {Narrator})" - metadata = { - "Author": "Andy Weir", - "Title": "The Martian", - "Narrator": "R.C. Bray", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - # If Narrator token is supported - if "Narrator" in str(path): - assert "narrated by R.C. Bray" in path.name - - -class TestRealWorldNamingScenarios: - """Test real-world naming scenarios users would encounter.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_realworld_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - def test_plex_audiobook_naming(self, temp_dir): - """Plex-style audiobook naming: Author/Book/Book.m4b""" - template = "{Author}/{Title}/{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Title": "Mistborn", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - assert "Brandon Sanderson/Mistborn/Mistborn.m4b" in str(path).replace("\\", "/") - - def test_audiobookshelf_naming(self, temp_dir): - """Audiobookshelf-style: Author/Series/Book""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "Brandon Sanderson", - "Series": "Mistborn", - "Title": "The Final Empire", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="m4b") - - parts = str(path).replace("\\", "/").split("/") - assert "Brandon Sanderson" in parts - assert "Mistborn" in parts - assert "The Final Empire.m4b" in parts[-1] - - def test_calibre_style_naming(self, temp_dir): - """Calibre-style: Author/Title (ID)/Title.epub""" - # This requires an ID field which may not be supported - template = "{Author}/{Title}/{Title}" - metadata = { - "Author": "Frank Herbert", - "Title": "Dune", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Frank Herbert/Dune/Dune.epub" in str(path).replace("\\", "/") - - def test_simple_flat_naming(self, temp_dir): - """Simple flat structure: Author - Title.epub""" - template = "{Author} - {Title}" - metadata = { - "Author": "Frank Herbert", - "Title": "Dune", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "Frank Herbert - Dune.epub" - assert path.parent == temp_dir.resolve() - - def test_year_based_organization(self, temp_dir): - """Year-based: Year/Author/Title.epub""" - template = "{Year}/{Author}/{Title}" - metadata = { - "Year": 1965, - "Author": "Frank Herbert", - "Title": "Dune", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "1965/Frank Herbert/Dune.epub" in str(path).replace("\\", "/") - - def test_series_position_with_leading_zero(self, temp_dir): - """Series position with leading zero: 01 - Title""" - template = "{Author}/{Series/}{SeriesPosition - }{Title}" - metadata = { - "Author": "Author", - "Series": "Series", - "SeriesPosition": 1, - "Title": "First Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Position might be "1 -" or "01 -" depending on implementation - assert "First Book" in path.name - - def test_multiauthor_book(self, temp_dir): - """Book with multiple authors.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Neil Gaiman & Terry Pratchett", - "Title": "Good Omens", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Ampersand might be preserved or sanitized - assert "Good Omens.epub" == path.name - - def test_book_with_colon_in_title(self, temp_dir): - """Book with colon in title (common in subtitles).""" - template = "{Author}/{Title}" - metadata = { - "Author": "Author", - "Title": "Main Title: The Subtitle", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Colon should be sanitized (invalid on Windows) - assert ":" not in path.name - - def test_book_with_numbers_in_title(self, temp_dir): - """Book with numbers in title.""" - template = "{Author}/{Title}" - metadata = { - "Author": "George Orwell", - "Title": "1984", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "1984.epub" - - def test_anthology_naming(self, temp_dir): - """Anthology with editor instead of author.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Various Authors (Ed. John Smith)", - "Title": "Best SF Stories 2024", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert "Best SF Stories 2024.epub" == path.name - - -class TestEdgeCasesAndBoundaries: - """Test edge cases and boundary conditions.""" - - @pytest.fixture - def temp_dir(self): - """Create temporary directory.""" - d = tempfile.mkdtemp(prefix="test_edge_") - yield Path(d) - shutil.rmtree(d, ignore_errors=True) - - def test_all_fields_none(self, temp_dir): - """All metadata fields are None.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": None, - "Series": None, - "Title": None, - } - - # Should handle gracefully, not crash - try: - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - # If it succeeds, should have some valid path - assert path.suffix == ".epub" - except ValueError: - # Or might raise an error for completely empty metadata - pass - - def test_all_fields_empty_string(self, temp_dir): - """All metadata fields are empty strings.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "", - "Series": "", - "Title": "", - } - - try: - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - assert path.suffix == ".epub" - except ValueError: - pass - - def test_metadata_with_extra_fields(self, temp_dir): - """Metadata with extra fields not in template.""" - template = "{Author}/{Title}" - metadata = { - "Author": "Author", - "Title": "Book", - "ISBN": "1234567890", - "Publisher": "Big Publisher", - "RandomField": "Random Value", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Extra fields should be ignored - assert path.name == "Book.epub" - assert "ISBN" not in str(path) - - def test_template_with_unknown_token(self, temp_dir): - """Template with token not in metadata.""" - template = "{Author}/{UnknownToken}/{Title}" - metadata = { - "Author": "Author", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Unknown token should be handled (skipped or empty) - - def test_template_with_literal_braces(self, temp_dir): - """Template with literal curly braces (escaped).""" - # This tests if there's a way to escape braces - template = "{Author}/{{Not A Token}}/{Title}" - metadata = { - "Author": "Author", - "Title": "Book", - } - - try: - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - # Behavior depends on implementation - except Exception: - pass # Might not support escaped braces - - def test_extremely_nested_path(self, temp_dir): - """Very deeply nested folder structure.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "A/B/C/D", # Slashes in author name - "Series": "Series", - "Title": "Book", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Slashes in field values should be sanitized - - def test_path_component_exactly_255_chars(self, temp_dir): - """Path component at exactly filesystem limit.""" - template = "{Title}" - metadata = { - "Title": "A" * 255, # Exactly 255 chars - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Might need to truncate to make room for extension - - def test_total_path_very_long(self, temp_dir): - """Total path approaching filesystem limits.""" - template = "{Author}/{Series/}{Title}" - metadata = { - "Author": "A" * 200, - "Series": "S" * 200, - "Title": "T" * 200, - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - # Should handle gracefully - - def test_numeric_string_values(self, temp_dir): - """Metadata values that are numeric strings.""" - template = "{Author}/{Title}" - metadata = { - "Author": "123", - "Title": "456", - } - - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - - assert path.name == "456.epub" - assert path.parent.name == "123" - - def test_boolean_metadata_values(self, temp_dir): - """Metadata values that are booleans (unusual but possible).""" - template = "{Author}/{Title}" - metadata = { - "Author": True, # Boolean value - "Title": "Book", - } - - try: - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - # Should convert to string - except (TypeError, ValueError): - pass # Or might fail - - def test_list_metadata_values(self, temp_dir): - """Metadata values that are lists (e.g., multiple authors).""" - template = "{Author}/{Title}" - metadata = { - "Author": ["Author 1", "Author 2"], # List value - "Title": "Book", - } - - try: - path = build_library_path(str(temp_dir), template, metadata, extension="epub") - # Should convert to string somehow - except (TypeError, ValueError): - pass # Or might fail - - -class TestIntegration: - """Integration tests combining multiple scenarios.""" - - @pytest.fixture - def full_setup(self): - """Create a complete test environment.""" - base = tempfile.mkdtemp(prefix="test_integration_") - - dirs = { - "books_lib": Path(base) / "books_library", - "audiobooks_lib": Path(base) / "audiobooks_library", - "books_ingest": Path(base) / "books_ingest", - "audiobooks_ingest": Path(base) / "audiobooks_ingest", - "staging": Path(base) / "staging", - } - - for d in dirs.values(): - d.mkdir(parents=True) - - yield dirs - - shutil.rmtree(base, ignore_errors=True) - - def test_full_workflow_books_library_audiobooks_ingest(self, full_setup): - """Complete workflow: books to library, audiobooks to ingest.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH=str(full_setup["books_lib"]), - LIBRARY_TEMPLATE="{Author}/{Series/}{SeriesPosition - }{Title}", - PROCESSING_MODE_AUDIOBOOK="ingest", - INGEST_DIR_AUDIOBOOK=str(full_setup["audiobooks_ingest"]), - ) - - # Create test files - book_file = full_setup["staging"] / "test_book.epub" - book_file.write_text("epub content") - - audiobook_file = full_setup["staging"] / "test_audiobook.m4b" - audiobook_file.write_text("m4b content") - - # Book task - book_task = DownloadTask( - task_id="book-int-1", - source="prowlarr", - title="The Final Empire", - author="Brandon Sanderson", - series_name="Mistborn", - series_position=1, - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - # Audiobook task - audiobook_task = DownloadTask( - task_id="audiobook-int-1", - source="prowlarr", - title="Words of Radiance", - author="Brandon Sanderson", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - # Determine processing for book - is_book_audiobook = "audiobook" in (book_task.content_type or "").lower() - book_mode = config.get("PROCESSING_MODE_AUDIOBOOK") if is_book_audiobook else config.get("PROCESSING_MODE") - - assert book_mode == "library" - - # Build book destination - book_metadata = { - "Author": book_task.author, - "Title": book_task.title, - "Series": book_task.series_name, - "SeriesPosition": book_task.series_position, - } - book_dest = build_library_path( - config.get("LIBRARY_PATH"), - config.get("LIBRARY_TEMPLATE"), - book_metadata, - extension="epub" - ) - - # Move book to library - book_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(book_file), str(book_dest)) - - # Determine processing for audiobook - is_audio_audiobook = "audiobook" in (audiobook_task.content_type or "").lower() - audio_mode = config.get("PROCESSING_MODE_AUDIOBOOK") if is_audio_audiobook else config.get("PROCESSING_MODE") - - assert audio_mode == "ingest" - - # Move audiobook to ingest - ingest_dir = Path(config.get("INGEST_DIR_AUDIOBOOK")) - audiobook_dest = ingest_dir / audiobook_file.name - shutil.move(str(audiobook_file), str(audiobook_dest)) - - # Verify results - assert book_dest.exists() - assert audiobook_dest.exists() - - # Book should be in organized structure - assert "Mistborn" in str(book_dest) - assert "1 - The Final Empire" in str(book_dest) - - # Audiobook should be in flat ingest directory - assert audiobook_dest.parent == ingest_dir - - def test_full_workflow_both_library_mode(self, full_setup): - """Complete workflow: both books and audiobooks in library mode.""" - config = MockConfig( - PROCESSING_MODE="library", - LIBRARY_PATH=str(full_setup["books_lib"]), - LIBRARY_TEMPLATE="{Author}/{Title}", - PROCESSING_MODE_AUDIOBOOK="library", - LIBRARY_PATH_AUDIOBOOK=str(full_setup["audiobooks_lib"]), - LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", - ) - - # Create test files - book_file = full_setup["staging"] / "test_book.epub" - book_file.write_text("epub content") - - audiobook_file = full_setup["staging"] / "test_audiobook.m4b" - audiobook_file.write_text("m4b content") - - # Book task - book_task = DownloadTask( - task_id="book-int-2", - source="prowlarr", - title="Dune", - author="Frank Herbert", - content_type="book (fiction)", - search_mode=SearchMode.UNIVERSAL, - ) - - # Audiobook task with series - audiobook_task = DownloadTask( - task_id="audiobook-int-2", - source="prowlarr", - title="Dune", - author="Frank Herbert", - series_name="Dune Chronicles", - content_type="Audiobook", - search_mode=SearchMode.UNIVERSAL, - ) - - # Process book - book_metadata = {"Author": book_task.author, "Title": book_task.title} - book_dest = build_library_path( - config.get("LIBRARY_PATH"), - config.get("LIBRARY_TEMPLATE"), - book_metadata, - extension="epub" - ) - book_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(book_file), str(book_dest)) - - # Process audiobook - audiobook_metadata = { - "Author": audiobook_task.author, - "Title": audiobook_task.title, - "Series": audiobook_task.series_name, - } - audiobook_dest = build_library_path( - config.get("LIBRARY_PATH_AUDIOBOOK"), - config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), - audiobook_metadata, - extension="m4b" - ) - audiobook_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(audiobook_file), str(audiobook_dest)) - - # Verify results - assert book_dest.exists() - assert audiobook_dest.exists() - - # Book: /books_lib/Frank Herbert/Dune.epub - assert book_dest.parent.name == "Frank Herbert" - - # Audiobook: /audiobooks_lib/Frank Herbert/Dune Chronicles/Dune.m4b - assert "Dune Chronicles" in str(audiobook_dest) - assert audiobook_dest.parent.name == "Dune Chronicles" diff --git a/tests/core/test_manual_query.py b/tests/core/test_manual_query.py index d1bb8cd..7b74ad6 100644 --- a/tests/core/test_manual_query.py +++ b/tests/core/test_manual_query.py @@ -1,5 +1,5 @@ -from shelfmark.metadata_providers import BookMetadata from shelfmark.core.search_plan import build_release_search_plan +from shelfmark.metadata_providers import BookMetadata class TestReleaseSearchPlanManualQuery: @@ -26,4 +26,6 @@ class TestReleaseSearchPlanManualQuery: assert plan.languages == ["en", "hu"] assert [v.query for v in plan.title_variants] == ["some custom query"] assert [v.title for v in plan.title_variants] == ["some custom query"] - assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [("some custom query", None)] + assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [ + ("some custom query", None) + ] diff --git a/tests/core/test_naming.py b/tests/core/test_naming.py index 63cf1a7..b5b7a81 100644 --- a/tests/core/test_naming.py +++ b/tests/core/test_naming.py @@ -2,25 +2,23 @@ Tests for the naming module - template parsing and library path building. """ -import pytest -from pathlib import Path -import tempfile -import os import shutil +import tempfile +from pathlib import Path + +import pytest from shelfmark.core.naming import ( - natural_sort_key, assign_part_numbers, - parse_naming_template, build_library_path, - sanitize_filename, - sanitize_path_component, format_series_position, + natural_sort_key, + parse_naming_template, + sanitize_filename, ) class TestNaturalSortAndAssignment: - def test_natural_sort_simple_numbers(self): files = ["Part 2.mp3", "Part 10.mp3", "Part 1.mp3"] assert sorted(files, key=natural_sort_key) == ["Part 1.mp3", "Part 2.mp3", "Part 10.mp3"] @@ -28,7 +26,10 @@ class TestNaturalSortAndAssignment: def test_natural_sort_cd_track_pattern(self): files = ["CD2_Track10.mp3", "CD1_Track2.mp3", "CD1_Track10.mp3", "CD2_Track1.mp3"] assert sorted(files, key=natural_sort_key) == [ - "CD1_Track2.mp3", "CD1_Track10.mp3", "CD2_Track1.mp3", "CD2_Track10.mp3" + "CD1_Track2.mp3", + "CD1_Track10.mp3", + "CD2_Track1.mp3", + "CD2_Track10.mp3", ] def test_assign_part_numbers_empty(self): @@ -37,12 +38,17 @@ class TestNaturalSortAndAssignment: def test_assign_part_numbers_sorted(self): files = [Path("Part 3.mp3"), Path("Part 1.mp3"), Path("Part 2.mp3")] assert assign_part_numbers(files) == [ - (Path("Part 1.mp3"), "01"), (Path("Part 2.mp3"), "02"), (Path("Part 3.mp3"), "03") + (Path("Part 1.mp3"), "01"), + (Path("Part 2.mp3"), "02"), + (Path("Part 3.mp3"), "03"), ] def test_assign_part_numbers_custom_padding(self): files = [Path("a.mp3"), Path("b.mp3")] - assert assign_part_numbers(files, zero_pad_width=3) == [(Path("a.mp3"), "001"), (Path("b.mp3"), "002")] + assert assign_part_numbers(files, zero_pad_width=3) == [ + (Path("a.mp3"), "001"), + (Path("b.mp3"), "002"), + ] def test_no_false_positives_fahrenheit_451(self): files = [Path("Fahrenheit 451 - Part 2.mp3"), Path("Fahrenheit 451 - Part 1.mp3")] @@ -56,8 +62,7 @@ class TestParseNamingTemplate: def test_simple_substitution(self): """Test basic token replacement.""" result = parse_naming_template( - "{Author}/{Title}", - {"Author": "Brandon Sanderson", "Title": "The Way of Kings"} + "{Author}/{Title}", {"Author": "Brandon Sanderson", "Title": "The Way of Kings"} ) assert result == "Brandon Sanderson/The Way of Kings" @@ -66,19 +71,20 @@ class TestParseNamingTemplate: template = "{Author}/{Series/}{Title}" # With series - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "Title": "The Way of Kings" - }) + result = parse_naming_template( + template, + { + "Author": "Brandon Sanderson", + "Series": "Stormlight Archive", + "Title": "The Way of Kings", + }, + ) assert result == "Brandon Sanderson/Stormlight Archive/The Way of Kings" # Without series - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Series": None, - "Title": "The Way of Kings" - }) + result = parse_naming_template( + template, {"Author": "Brandon Sanderson", "Series": None, "Title": "The Way of Kings"} + ) assert result == "Brandon Sanderson/The Way of Kings" def test_conditional_prefix(self): @@ -86,17 +92,13 @@ class TestParseNamingTemplate: template = "{Title}{ - Subtitle}" # With subtitle - result = parse_naming_template(template, { - "Title": "The Way of Kings", - "Subtitle": "Journey Before Destination" - }) + result = parse_naming_template( + template, {"Title": "The Way of Kings", "Subtitle": "Journey Before Destination"} + ) assert result == "The Way of Kings - Journey Before Destination" # Without subtitle - result = parse_naming_template(template, { - "Title": "The Way of Kings", - "Subtitle": None - }) + result = parse_naming_template(template, {"Title": "The Way of Kings", "Subtitle": None}) assert result == "The Way of Kings" def test_subtitle_token(self): @@ -104,7 +106,7 @@ class TestParseNamingTemplate: metadata = { "Author": "Brandon Sanderson", "Title": "The Way of Kings", - "Subtitle": "Book One of the Stormlight Archive" + "Subtitle": "Book One of the Stormlight Archive", } # Subtitle after title @@ -117,11 +119,7 @@ class TestParseNamingTemplate: def test_part_number_token(self): """Test PartNumber in templates.""" - metadata = { - "Author": "Brandon Sanderson", - "Title": "The Way of Kings", - "PartNumber": "01" - } + metadata = {"Author": "Brandon Sanderson", "Title": "The Way of Kings", "PartNumber": "01"} # Literal " - Part " in template result = parse_naming_template("{Author}/{Title} - Part {PartNumber}", metadata) @@ -133,11 +131,7 @@ class TestParseNamingTemplate: def test_part_number_without_value(self): """Test PartNumber when not provided.""" - metadata = { - "Author": "Brandon Sanderson", - "Title": "The Way of Kings", - "PartNumber": None - } + metadata = {"Author": "Brandon Sanderson", "Title": "The Way of Kings", "PartNumber": None} # Conditional prefix: " - " only appears if PartNumber has value result = parse_naming_template("{Author}/{Title}{ - PartNumber}", metadata) @@ -162,24 +156,19 @@ class TestParseNamingTemplate: def test_year_token(self): """Test year in templates.""" result = parse_naming_template( - "{Author}/{Title} ({Year})", - {"Author": "Sanderson", "Title": "Book", "Year": 2010} + "{Author}/{Title} ({Year})", {"Author": "Sanderson", "Title": "Book", "Year": 2010} ) assert result == "Sanderson/Book (2010)" def test_case_insensitive_tokens(self): """Test that token matching is case-insensitive.""" - result = parse_naming_template( - "{author}/{TITLE}", - {"Author": "Sanderson", "Title": "Book"} - ) + result = parse_naming_template("{author}/{TITLE}", {"Author": "Sanderson", "Title": "Book"}) assert result == "Sanderson/Book" def test_special_characters_sanitized(self): """Test that special characters are sanitized.""" result = parse_naming_template( - "{Author}/{Title}", - {"Author": "Author: Name", "Title": "Book: Subtitle?"} + "{Author}/{Title}", {"Author": "Author: Name", "Title": "Book: Subtitle?"} ) assert ":" not in result assert "?" not in result @@ -198,21 +187,23 @@ class TestParseNamingTemplate: template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" # All fields present - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Series": "Stormlight", - "SeriesPosition": 1, - "Title": "The Way of Kings", - "Subtitle": "Epic Fantasy", - "Year": 2010 - }) + result = parse_naming_template( + template, + { + "Author": "Brandon Sanderson", + "Series": "Stormlight", + "SeriesPosition": 1, + "Title": "The Way of Kings", + "Subtitle": "Epic Fantasy", + "Year": 2010, + }, + ) assert result == "Brandon Sanderson/Stormlight/1 - The Way of Kings - Epic Fantasy (2010)" # Minimal fields - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Title": "The Way of Kings" - }) + result = parse_naming_template( + template, {"Author": "Brandon Sanderson", "Title": "The Way of Kings"} + ) assert result == "Brandon Sanderson/The Way of Kings" @@ -222,72 +213,60 @@ class TestArbitraryPrefixSuffix: def test_vol_prefix_with_value(self): """Test {Vol. SeriesPosition - } with a value.""" result = parse_naming_template( - "{Vol. SeriesPosition - }{Title}", - {"SeriesPosition": 2, "Title": "Book Title"} + "{Vol. SeriesPosition - }{Title}", {"SeriesPosition": 2, "Title": "Book Title"} ) assert result == "Vol. 2 - Book Title" def test_vol_prefix_without_value(self): """Test {Vol. SeriesPosition - } without a value produces nothing.""" result = parse_naming_template( - "{Vol. SeriesPosition - }{Title}", - {"SeriesPosition": None, "Title": "Book Title"} + "{Vol. SeriesPosition - }{Title}", {"SeriesPosition": None, "Title": "Book Title"} ) assert result == "Book Title" def test_vol_prefix_empty_string(self): """Test {Vol. SeriesPosition - } with empty string produces nothing.""" result = parse_naming_template( - "{Vol. SeriesPosition - }{Title}", - {"SeriesPosition": "", "Title": "Book Title"} + "{Vol. SeriesPosition - }{Title}", {"SeriesPosition": "", "Title": "Book Title"} ) assert result == "Book Title" def test_book_x_of_series_pattern(self): """Test {Book SeriesPosition of the Series} pattern.""" result = parse_naming_template( - "{Book SeriesPosition of the Series}", - {"SeriesPosition": 2, "Series": "Stormlight"} + "{Book SeriesPosition of the Series}", {"SeriesPosition": 2, "Series": "Stormlight"} ) assert result == "Book 2 of the Series" def test_case_insensitive_arbitrary_prefix(self): """Test case-insensitive token matching with arbitrary prefix.""" result = parse_naming_template( - "{vol. seriesposition - }{Title}", - {"SeriesPosition": 3, "Title": "Book"} + "{vol. seriesposition - }{Title}", {"SeriesPosition": 3, "Title": "Book"} ) assert result == "vol. 3 - Book" def test_arbitrary_prefix_with_part_number(self): """Test arbitrary prefix with PartNumber token.""" - result = parse_naming_template( - "{Part PartNumber}", - {"PartNumber": "05"} - ) + result = parse_naming_template("{Part PartNumber}", {"PartNumber": "05"}) assert result == "Part 05" def test_arbitrary_prefix_part_number_empty(self): """Test arbitrary prefix with empty PartNumber.""" result = parse_naming_template( - "{Title}{Part PartNumber}", - {"Title": "Book", "PartNumber": None} + "{Title}{Part PartNumber}", {"Title": "Book", "PartNumber": None} ) assert result == "Book" def test_no_variable_in_block_unchanged(self): """Test that blocks without known variables are left unchanged.""" - result = parse_naming_template( - "{literal text}", - {"Title": "Book"} - ) + result = parse_naming_template("{literal text}", {"Title": "Book"}) assert result == "{literal text}" def test_mixed_legacy_and_new_syntax(self): """Test mixed template with both legacy and new syntax.""" result = parse_naming_template( "{Author}/{Vol. SeriesPosition - }{Title}", - {"Author": "Sanderson", "SeriesPosition": 1, "Title": "Mistborn"} + {"Author": "Sanderson", "SeriesPosition": 1, "Title": "Mistborn"}, ) assert result == "Sanderson/Vol. 1 - Mistborn" @@ -295,48 +274,39 @@ class TestArbitraryPrefixSuffix: """Test mixed template when series position is empty.""" result = parse_naming_template( "{Author}/{Vol. SeriesPosition - }{Title}", - {"Author": "Sanderson", "SeriesPosition": None, "Title": "Elantris"} + {"Author": "Sanderson", "SeriesPosition": None, "Title": "Elantris"}, ) assert result == "Sanderson/Elantris" def test_subtitle_with_arbitrary_prefix(self): """Test Subtitle token with arbitrary prefix text.""" result = parse_naming_template( - "{Title}{: Subtitle}", - {"Title": "Main", "Subtitle": "Secondary"} + "{Title}{: Subtitle}", {"Title": "Main", "Subtitle": "Secondary"} ) assert result == "Main: Secondary" def test_year_with_arbitrary_prefix_suffix(self): """Test Year with arbitrary text around it.""" - result = parse_naming_template( - "{Title} {(Year)}", - {"Title": "Book", "Year": 2020} - ) + result = parse_naming_template("{Title} {(Year)}", {"Title": "Book", "Year": 2020}) assert result == "Book (2020)" def test_year_empty_with_arbitrary_prefix_suffix(self): """Test Year with arbitrary text when Year is empty.""" - result = parse_naming_template( - "{Title} {(Year)}", - {"Title": "Book", "Year": None} - ) + result = parse_naming_template("{Title} {(Year)}", {"Title": "Book", "Year": None}) # Note: trailing space gets cleaned up assert result == "Book" def test_series_position_longest_match(self): """Test that SeriesPosition matches before Series.""" result = parse_naming_template( - "{SeriesPosition - }{Series}", - {"SeriesPosition": 1, "Series": "Stormlight"} + "{SeriesPosition - }{Series}", {"SeriesPosition": 1, "Series": "Stormlight"} ) assert result == "1 - Stormlight" def test_arbitrary_prefix_float_position(self): """Test arbitrary prefix with float series position.""" result = parse_naming_template( - "{Vol. SeriesPosition - }{Title}", - {"SeriesPosition": 1.5, "Title": "Novella"} + "{Vol. SeriesPosition - }{Title}", {"SeriesPosition": 1.5, "Title": "Novella"} ) assert result == "Vol. 1.5 - Novella" @@ -345,21 +315,26 @@ class TestArbitraryPrefixSuffix: template = "{Author}/{Series/}{Vol. SeriesPosition - }{Title}{: Subtitle} {(Year)}" # All fields present - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Series": "Stormlight Archive", - "SeriesPosition": 1, - "Title": "The Way of Kings", - "Subtitle": "Epic Fantasy", - "Year": 2010 - }) - assert result == "Brandon Sanderson/Stormlight Archive/Vol. 1 - The Way of Kings: Epic Fantasy (2010)" + result = parse_naming_template( + template, + { + "Author": "Brandon Sanderson", + "Series": "Stormlight Archive", + "SeriesPosition": 1, + "Title": "The Way of Kings", + "Subtitle": "Epic Fantasy", + "Year": 2010, + }, + ) + assert ( + result + == "Brandon Sanderson/Stormlight Archive/Vol. 1 - The Way of Kings: Epic Fantasy (2010)" + ) # Minimal fields - result = parse_naming_template(template, { - "Author": "Brandon Sanderson", - "Title": "Standalone Novel" - }) + result = parse_naming_template( + template, {"Author": "Brandon Sanderson", "Title": "Standalone Novel"} + ) assert result == "Brandon Sanderson/Standalone Novel" @@ -369,10 +344,7 @@ class TestBuildLibraryPath: def test_basic_path(self): """Test basic path building.""" path = build_library_path( - "/books", - "{Author}/{Title}", - {"Author": "Sanderson", "Title": "Book"}, - extension="epub" + "/books", "{Author}/{Title}", {"Author": "Sanderson", "Title": "Book"}, extension="epub" ) assert path == Path("/books/Sanderson/Book.epub") @@ -382,7 +354,7 @@ class TestBuildLibraryPath: "/books", "{Author}/{Title}{ - Subtitle}", {"Author": "Sanderson", "Title": "Book", "Subtitle": "A Novel"}, - extension="epub" + extension="epub", ) assert path == Path("/books/Sanderson/Book - A Novel.epub") @@ -392,37 +364,26 @@ class TestBuildLibraryPath: "/audiobooks", "{Author}/{Title} - Part {PartNumber}", {"Author": "Sanderson", "Title": "Book", "PartNumber": "01"}, - extension="mp3" + extension="mp3", ) assert path == Path("/audiobooks/Sanderson/Book - Part 01.mp3") def test_path_traversal_prevented(self): """Test that path traversal is prevented.""" path = build_library_path( - "/books", - "{Author}/{Title}", - {"Author": "../etc", "Title": "passwd"}, - extension="txt" + "/books", "{Author}/{Title}", {"Author": "../etc", "Title": "passwd"}, extension="txt" ) assert path == Path("/books/etc/passwd.txt") def test_fallback_to_title(self): """Test fallback when template produces empty result.""" - path = build_library_path( - "/books", - "{Series/}{Title}", - {"Title": "Book"}, - extension="epub" - ) + path = build_library_path("/books", "{Series/}{Title}", {"Title": "Book"}, extension="epub") assert "Book" in str(path) def test_no_extension(self): """Test path without extension.""" path = build_library_path( - "/books", - "{Author}/{Title}", - {"Author": "Sanderson", "Title": "Book"}, - extension=None + "/books", "{Author}/{Title}", {"Author": "Sanderson", "Title": "Book"}, extension=None ) assert path == Path("/books/Sanderson/Book") @@ -430,16 +391,19 @@ class TestBuildLibraryPath: class TestSanitizeFilename: """Tests for filename sanitization.""" - @pytest.mark.parametrize("input_name,expected", [ - ("normal_file", "normal_file"), - ("file:with:colons", "file_with_colons"), - ("file*with*stars", "file_with_stars"), - ("file?with?questions", "file_with_questions"), - ('file"with"quotes', "file_with_quotes"), - ("fileangles", "file_with_angles"), - ("file|with|pipes", "file_with_pipes"), - ("file/with/slash", "file_with_slash"), - ]) + @pytest.mark.parametrize( + "input_name,expected", + [ + ("normal_file", "normal_file"), + ("file:with:colons", "file_with_colons"), + ("file*with*stars", "file_with_stars"), + ("file?with?questions", "file_with_questions"), + ('file"with"quotes', "file_with_quotes"), + ("fileangles", "file_with_angles"), + ("file|with|pipes", "file_with_pipes"), + ("file/with/slash", "file_with_slash"), + ], + ) def test_invalid_chars_replaced(self, input_name, expected): """Test that invalid characters are replaced.""" assert sanitize_filename(input_name) == expected @@ -516,7 +480,7 @@ class TestIntegration: # Use assign_part_numbers to sort and number sequentially files_with_parts = assign_part_numbers(files) - for file_path, part_num in files_with_parts: + for _file_path, part_num in files_with_parts: file_metadata = {**base_metadata, "PartNumber": part_num} path = build_library_path("/audiobooks", template, file_metadata, extension="mp3") @@ -667,10 +631,7 @@ class TestFilesystemOperations: def test_special_characters_in_folder_names(self, temp_library): """Test that special characters are sanitized in folder names.""" - metadata = { - "Author": "Author: With Colons", - "Title": "Book? With Characters*" - } + metadata = {"Author": "Author: With Colons", "Title": "Book? With Characters*"} template = "{Author}/{Title}" path = build_library_path(str(temp_library), template, metadata, extension="epub") diff --git a/tests/core/test_notifications_settings_api.py b/tests/core/test_notifications_settings_api.py index 2c5f5ef..ef8ea35 100644 --- a/tests/core/test_notifications_settings_api.py +++ b/tests/core/test_notifications_settings_api.py @@ -44,7 +44,9 @@ class TestNotificationsSettingsApi: with patch.object(main_module, "get_auth_mode", return_value="builtin"): resp = client.post( "/api/settings/notifications/action/test_admin_notification", - json={"ADMIN_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/demo"}]}, + json={ + "ADMIN_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/demo"}] + }, ) assert resp.status_code == 403 diff --git a/tests/core/test_oidc_auth.py b/tests/core/test_oidc_auth.py index 18583be..3241b7c 100644 --- a/tests/core/test_oidc_auth.py +++ b/tests/core/test_oidc_auth.py @@ -5,11 +5,10 @@ Tests the OIDCAuth helper: login URL generation, callback handling, user provisioning, and group claim parsing. """ - import os import tempfile -import pytest +import pytest MOCK_DISCOVERY = { "issuer": "https://auth.example.com", @@ -40,6 +39,7 @@ def db_path(): @pytest.fixture def user_db(db_path): from shelfmark.core.user_db import UserDB + db = UserDB(db_path) db.initialize() return db @@ -50,6 +50,7 @@ class TestParseGroupClaims: def test_parse_groups_list(self): from shelfmark.core.oidc_auth import parse_group_claims + id_token = {"groups": ["admins", "users", "shelfmark-admins"]} groups = parse_group_claims(id_token, "groups") assert "shelfmark-admins" in groups @@ -57,24 +58,28 @@ class TestParseGroupClaims: def test_parse_groups_comma_separated_string(self): from shelfmark.core.oidc_auth import parse_group_claims + id_token = {"groups": "admins, users, shelfmark-admins"} groups = parse_group_claims(id_token, "groups") assert "shelfmark-admins" in groups def test_parse_groups_pipe_separated_string(self): from shelfmark.core.oidc_auth import parse_group_claims + id_token = {"groups": "admins|users|shelfmark-admins"} groups = parse_group_claims(id_token, "groups") assert "shelfmark-admins" in groups def test_parse_groups_missing_claim(self): from shelfmark.core.oidc_auth import parse_group_claims + id_token = {"email": "user@example.com"} groups = parse_group_claims(id_token, "groups") assert groups == [] def test_parse_groups_empty(self): from shelfmark.core.oidc_auth import parse_group_claims + id_token = {"groups": []} groups = parse_group_claims(id_token, "groups") assert groups == [] @@ -116,6 +121,7 @@ class TestExtractUserInfo: def test_extract_standard_claims(self): from shelfmark.core.oidc_auth import extract_user_info + id_token = { "sub": "user-123", "email": "john@example.com", @@ -130,6 +136,7 @@ class TestExtractUserInfo: def test_extract_falls_back_to_email_for_username(self): from shelfmark.core.oidc_auth import extract_user_info + id_token = { "sub": "user-123", "email": "john@example.com", @@ -140,6 +147,7 @@ class TestExtractUserInfo: def test_extract_falls_back_to_sub_for_username(self): from shelfmark.core.oidc_auth import extract_user_info + id_token = { "sub": "user-123", } @@ -148,6 +156,7 @@ class TestExtractUserInfo: def test_extract_handles_missing_optional_fields(self): from shelfmark.core.oidc_auth import extract_user_info + id_token = {"sub": "user-123"} info = extract_user_info(id_token) assert info["oidc_subject"] == "user-123" @@ -160,6 +169,7 @@ class TestProvisionOIDCUser: def test_provision_creates_new_user(self, user_db): from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -174,6 +184,7 @@ class TestProvisionOIDCUser: def test_provision_creates_admin_user(self, user_db): from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -185,6 +196,7 @@ class TestProvisionOIDCUser: def test_provision_returns_existing_user(self, user_db): from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -197,6 +209,7 @@ class TestProvisionOIDCUser: def test_provision_updates_existing_user_info(self, user_db): from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -214,6 +227,7 @@ class TestProvisionOIDCUser: def test_provision_updates_admin_role(self, user_db): from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -229,6 +243,7 @@ class TestProvisionOIDCUser: def test_provision_preserves_role_when_group_auth_disabled(self, user_db): """When is_admin=None (group auth disabled), DB role should be preserved.""" from shelfmark.core.oidc_auth import provision_oidc_user + user_info = { "oidc_subject": "sub-123", "username": "john", @@ -246,6 +261,7 @@ class TestProvisionOIDCUser: def test_provision_handles_duplicate_username(self, user_db): """If OIDC subject is new but username exists, append suffix.""" from shelfmark.core.oidc_auth import provision_oidc_user + # Create a local user with the same username user_db.create_user(username="john", password_hash="hash") @@ -263,6 +279,7 @@ class TestProvisionOIDCUser: def test_provision_links_to_existing_user_by_email(self, user_db): """When allow_email_link=True and emails match, link to existing local user.""" from shelfmark.core.oidc_auth import provision_oidc_user + user_db.create_user( username="localuser", email="shared@example.com", @@ -276,7 +293,10 @@ class TestProvisionOIDCUser: "display_name": "OIDC User", } user = provision_oidc_user( - user_db, user_info, is_admin=False, allow_email_link=True, + user_db, + user_info, + is_admin=False, + allow_email_link=True, ) assert user["username"] == "localuser" assert user["oidc_subject"] == "oidc-sub-789" @@ -286,6 +306,7 @@ class TestProvisionOIDCUser: def test_provision_does_not_link_by_email_when_disabled(self, user_db): """When allow_email_link=False (default), don't link by email.""" from shelfmark.core.oidc_auth import provision_oidc_user + user_db.create_user( username="localuser", email="shared@example.com", @@ -299,7 +320,10 @@ class TestProvisionOIDCUser: "display_name": "OIDC User", } user = provision_oidc_user( - user_db, user_info, is_admin=False, allow_email_link=False, + user_db, + user_info, + is_admin=False, + allow_email_link=False, ) # Should create a new user, not link to existing assert user["username"] == "oidcuser" diff --git a/tests/core/test_oidc_integration.py b/tests/core/test_oidc_integration.py index 2920fdc..ef841f6 100644 --- a/tests/core/test_oidc_integration.py +++ b/tests/core/test_oidc_integration.py @@ -4,8 +4,8 @@ import sqlite3 from shelfmark.core.auth_modes import ( determine_auth_mode, - get_settings_tab_from_path, get_auth_check_admin_status, + get_settings_tab_from_path, is_settings_or_onboarding_path, load_active_auth_mode, requires_admin_for_settings_access, @@ -122,14 +122,20 @@ class TestSettingsRestrictionPolicy: assert requires_admin_for_settings_access("/api/settings/users", users_config) is True def test_other_tabs_also_require_admin(self): - assert requires_admin_for_settings_access( - "/api/settings/general", - {"RESTRICT_SETTINGS_TO_ADMIN": False}, - ) is True - assert requires_admin_for_settings_access( - "/api/settings/general", - {"RESTRICT_SETTINGS_TO_ADMIN": True}, - ) is True + assert ( + requires_admin_for_settings_access( + "/api/settings/general", + {"RESTRICT_SETTINGS_TO_ADMIN": False}, + ) + is True + ) + assert ( + requires_admin_for_settings_access( + "/api/settings/general", + {"RESTRICT_SETTINGS_TO_ADMIN": True}, + ) + is True + ) class TestAuthCheckAdminStatus: diff --git a/tests/core/test_oidc_routes.py b/tests/core/test_oidc_routes.py index f56c834..3612960 100644 --- a/tests/core/test_oidc_routes.py +++ b/tests/core/test_oidc_routes.py @@ -71,7 +71,9 @@ def client(app): class TestOIDCClientRegistration: - @patch("shelfmark.core.oidc_routes.app_config.get", side_effect=_config_getter(MOCK_OIDC_CONFIG)) + @patch( + "shelfmark.core.oidc_routes.app_config.get", side_effect=_config_getter(MOCK_OIDC_CONFIG) + ) @patch("shelfmark.core.oidc_routes.oauth.create_client") @patch("shelfmark.core.oidc_routes.oauth.register") def test_registers_client_with_pkce_and_expected_scopes( @@ -91,11 +93,12 @@ class TestOIDCClientRegistration: assert kwargs["server_metadata_url"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"] assert kwargs["overwrite"] is True assert kwargs["client_kwargs"]["code_challenge_method"] == "S256" - scope_str = kwargs["client_kwargs"]["scope"] - assert "openid" in scope_str - assert "email" in scope_str - assert "profile" in scope_str - assert "groups" in scope_str + assert set(kwargs["client_kwargs"]["scope"].split()) == { + "openid", + "email", + "profile", + "groups", + } @patch("shelfmark.core.oidc_routes.app_config.get") @patch("shelfmark.core.oidc_routes.oauth.create_client") @@ -117,7 +120,7 @@ class TestOIDCClientRegistration: _get_oidc_client() scope_str = mock_register.call_args.kwargs["client_kwargs"]["scope"] - assert "groups" not in scope_str + assert "groups" not in scope_str.split() class TestOIDCLoginEndpoint: @@ -135,7 +138,9 @@ class TestOIDCLoginEndpoint: redirect_uri = fake_client.authorize_redirect.call_args.args[0] assert redirect_uri.endswith("/api/auth/oidc/callback") - @patch("shelfmark.core.oidc_routes._get_oidc_client", side_effect=ValueError("OIDC not configured")) + @patch( + "shelfmark.core.oidc_routes._get_oidc_client", side_effect=ValueError("OIDC not configured") + ) def test_login_returns_500_when_not_configured(self, _mock_get_client, client): resp = client.get("/api/auth/oidc/login") assert resp.status_code == 500 @@ -165,6 +170,18 @@ class TestOIDCLoginEndpoint: with client.session_transaction() as sess: assert "oidc_return_to" not in sess + @patch("shelfmark.core.oidc_routes._get_oidc_client") + def test_login_ignores_api_return_to(self, mock_get_client, client): + fake_client = Mock() + fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize") + mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG) + + resp = client.get("/api/auth/oidc/login?return_to=%2Fapi%2Fusers") + + assert resp.status_code == 302 + with client.session_transaction() as sess: + assert "oidc_return_to" not in sess + class TestOIDCCallbackEndpoint: def test_normalize_claims_returns_empty_dict_for_invalid_mapping(self): @@ -197,6 +214,8 @@ class TestOIDCCallbackEndpoint: with client.session_transaction() as sess: assert sess["user_id"] == "john" assert sess["db_user_id"] is not None + assert sess["is_admin"] is False + assert sess.permanent is True @patch("shelfmark.core.oidc_routes._get_oidc_client") def test_callback_redirects_to_original_url_with_query(self, mock_get_client, client): @@ -286,6 +305,36 @@ class TestOIDCCallbackEndpoint: resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state") assert resp.status_code == 302 + fake_client.userinfo.assert_called_once_with(token={}) + + with client.session_transaction() as sess: + assert sess["user_id"] == "fallback" + + @patch("shelfmark.core.oidc_routes._get_oidc_client") + def test_callback_falls_back_to_legacy_userinfo_signature(self, mock_get_client, client): + fake_client = Mock() + token = {"userinfo": {"sub": "legacy-sub"}} + fake_client.authorize_access_token.return_value = token + fake_client.userinfo.side_effect = [ + TypeError("legacy signature"), + { + "sub": "legacy-sub", + "email": "legacy@example.com", + "preferred_username": "legacy", + "groups": [], + }, + ] + mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG) + + resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state") + + assert resp.status_code == 302 + assert fake_client.userinfo.call_args_list[0].kwargs == {"token": token} + assert fake_client.userinfo.call_args_list[1].kwargs == {} + with client.session_transaction() as sess: + assert sess["user_id"] == "legacy" + assert sess["db_user_id"] is not None + assert sess["is_admin"] is False @patch("shelfmark.core.oidc_routes._get_oidc_client") def test_callback_fetches_userinfo_when_token_claims_are_sparse(self, mock_get_client, client): @@ -340,7 +389,9 @@ class TestOIDCCallbackEndpoint: ): fake_client = Mock() fake_client.authorize_access_token.side_effect = InvalidClaimError("iss") - fake_client.load_server_metadata.return_value = {"issuer": "https://auth.example.com/application/o/shelfmark/"} + fake_client.load_server_metadata.return_value = { + "issuer": "https://auth.example.com/application/o/shelfmark/" + } mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG) resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state") @@ -457,6 +508,25 @@ class TestOIDCCallbackEndpoint: assert error is not None assert "Authentication failed" in error + @patch("shelfmark.core.oidc_routes._get_oidc_client") + def test_callback_preserves_return_to_on_error_redirect(self, mock_get_client, client): + fake_client = Mock() + fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize") + mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG) + + login_resp = client.get("/api/auth/oidc/login?return_to=%2Frequests%3Fq%3DSanderson") + assert login_resp.status_code == 302 + + resp = client.get("/api/auth/oidc/callback?error=access_denied") + + assert resp.status_code == 302 + parsed = urlparse(resp.headers["Location"]) + assert parsed.path == "/login" + assert parse_qs(parsed.query)["return_to"] == ["/requests?q=Sanderson"] + error = _get_oidc_error(resp) + assert error is not None + assert "Authentication failed" in error + @patch("shelfmark.core.oidc_routes._get_oidc_client") def test_callback_redirects_on_generic_exception(self, mock_get_client, client): fake_client = Mock() @@ -469,9 +539,7 @@ class TestOIDCCallbackEndpoint: assert "Authentication failed" in error @patch("shelfmark.core.oidc_routes._get_oidc_client") - def test_callback_links_to_existing_user_by_email( - self, mock_get_client, client, user_db - ): + def test_callback_links_to_existing_user_by_email(self, mock_get_client, client, user_db): """OIDC login with matching email should link to existing local user.""" user_db.create_user(username="localuser", email="shared@example.com", password_hash="hash") @@ -497,9 +565,7 @@ class TestOIDCCallbackEndpoint: assert linked["auth_source"] == "oidc" @patch("shelfmark.core.oidc_routes._get_oidc_client") - def test_callback_creates_new_user_when_no_email_match( - self, mock_get_client, client, user_db - ): + def test_callback_creates_new_user_when_no_email_match(self, mock_get_client, client, user_db): """OIDC login without matching email creates a new user.""" user_db.create_user(username="existing", email="other@example.com", password_hash="hash") @@ -524,9 +590,7 @@ class TestOIDCCallbackEndpoint: assert original["oidc_subject"] is None @patch("shelfmark.core.oidc_routes._get_oidc_client") - def test_callback_no_email_link_when_oidc_has_no_email( - self, mock_get_client, client, user_db - ): + def test_callback_no_email_link_when_oidc_has_no_email(self, mock_get_client, client, user_db): """OIDC login without email in claims should not attempt email linking.""" user_db.create_user(username="existing", email="existing@example.com", password_hash="hash") diff --git a/tests/core/test_part_number_extraction.py b/tests/core/test_part_number_extraction.py index b4d93dc..e10f441 100644 --- a/tests/core/test_part_number_extraction.py +++ b/tests/core/test_part_number_extraction.py @@ -1,19 +1,22 @@ """Tests for natural sort and sequential part number assignment.""" -import pytest from pathlib import Path -from shelfmark.core.naming import natural_sort_key, assign_part_numbers + +from shelfmark.core.naming import assign_part_numbers, natural_sort_key class TestNaturalSortKey: - def test_simple_numbers(self): files = ["Part 2.mp3", "Part 10.mp3", "Part 1.mp3"] assert sorted(files, key=natural_sort_key) == ["Part 1.mp3", "Part 2.mp3", "Part 10.mp3"] def test_leading_zeros(self): files = ["Track 01.mp3", "Track 10.mp3", "Track 02.mp3"] - assert sorted(files, key=natural_sort_key) == ["Track 01.mp3", "Track 02.mp3", "Track 10.mp3"] + assert sorted(files, key=natural_sort_key) == [ + "Track 01.mp3", + "Track 02.mp3", + "Track 10.mp3", + ] def test_case_insensitive(self): files = ["PART 2.mp3", "part 1.mp3", "Part 3.mp3"] @@ -22,7 +25,10 @@ class TestNaturalSortKey: def test_multiple_numbers_in_filename(self): files = ["CD2_Track10.mp3", "CD1_Track2.mp3", "CD1_Track10.mp3", "CD2_Track1.mp3"] assert sorted(files, key=natural_sort_key) == [ - "CD1_Track2.mp3", "CD1_Track10.mp3", "CD2_Track1.mp3", "CD2_Track10.mp3" + "CD1_Track2.mp3", + "CD1_Track10.mp3", + "CD2_Track1.mp3", + "CD2_Track10.mp3", ] def test_no_numbers(self): @@ -31,7 +37,11 @@ class TestNaturalSortKey: def test_path_objects(self): files = [Path("file10.mp3"), Path("file2.mp3"), Path("file1.mp3")] - assert [f.name for f in sorted(files, key=natural_sort_key)] == ["file1.mp3", "file2.mp3", "file10.mp3"] + assert [f.name for f in sorted(files, key=natural_sort_key)] == [ + "file1.mp3", + "file2.mp3", + "file10.mp3", + ] def test_uses_filename_only(self): files = [Path("/z/dir/file1.mp3"), Path("/a/dir/file2.mp3")] @@ -40,7 +50,6 @@ class TestNaturalSortKey: class TestAssignPartNumbers: - def test_empty_list(self): assert assign_part_numbers([]) == [] @@ -65,7 +74,10 @@ class TestAssignPartNumbers: def test_custom_zero_padding(self): files = [Path("a.mp3"), Path("b.mp3")] - assert assign_part_numbers(files, zero_pad_width=3) == [(Path("a.mp3"), "001"), (Path("b.mp3"), "002")] + assert assign_part_numbers(files, zero_pad_width=3) == [ + (Path("a.mp3"), "001"), + (Path("b.mp3"), "002"), + ] def test_many_files_padding(self): files = [Path(f"track_{i}.mp3") for i in range(100, 0, -1)] @@ -75,7 +87,6 @@ class TestAssignPartNumbers: class TestRealWorldScenarios: - def test_standard_part_naming(self): files = [ Path("The Way of Kings - Part 02.mp3"), @@ -92,28 +103,54 @@ class TestRealWorldScenarios: ] def test_cd_track_naming(self): - files = [Path("CD02_Track01.mp3"), Path("CD01_Track02.mp3"), Path("CD01_Track01.mp3"), Path("CD02_Track02.mp3")] + files = [ + Path("CD02_Track01.mp3"), + Path("CD01_Track02.mp3"), + Path("CD01_Track01.mp3"), + Path("CD02_Track02.mp3"), + ] result = assign_part_numbers(files) assert [r[0].name for r in result] == [ - "CD01_Track01.mp3", "CD01_Track02.mp3", "CD02_Track01.mp3", "CD02_Track02.mp3" + "CD01_Track01.mp3", + "CD01_Track02.mp3", + "CD02_Track01.mp3", + "CD02_Track02.mp3", ] def test_disc_track_naming(self): - files = [Path("Disc 1 - Track 10.mp3"), Path("Disc 1 - Track 2.mp3"), Path("Disc 2 - Track 1.mp3")] + files = [ + Path("Disc 1 - Track 10.mp3"), + Path("Disc 1 - Track 2.mp3"), + Path("Disc 2 - Track 1.mp3"), + ] result = assign_part_numbers(files) assert [r[0].name for r in result] == [ - "Disc 1 - Track 2.mp3", "Disc 1 - Track 10.mp3", "Disc 2 - Track 1.mp3" + "Disc 1 - Track 2.mp3", + "Disc 1 - Track 10.mp3", + "Disc 2 - Track 1.mp3", ] def test_simple_numbered_files(self): files = [Path("02 Chapter Two.mp3"), Path("01 Chapter One.mp3"), Path("10 Chapter Ten.mp3")] result = assign_part_numbers(files) - assert [r[0].name for r in result] == ["01 Chapter One.mp3", "02 Chapter Two.mp3", "10 Chapter Ten.mp3"] + assert [r[0].name for r in result] == [ + "01 Chapter One.mp3", + "02 Chapter Two.mp3", + "10 Chapter Ten.mp3", + ] def test_bracketed_numbers(self): - files = [Path("Book Title [03].mp3"), Path("Book Title [01].mp3"), Path("Book Title [02].mp3")] + files = [ + Path("Book Title [03].mp3"), + Path("Book Title [01].mp3"), + Path("Book Title [02].mp3"), + ] result = assign_part_numbers(files) - assert [r[0].name for r in result] == ["Book Title [01].mp3", "Book Title [02].mp3", "Book Title [03].mp3"] + assert [r[0].name for r in result] == [ + "Book Title [01].mp3", + "Book Title [02].mp3", + "Book Title [03].mp3", + ] class TestNoFalsePositives: @@ -126,9 +163,17 @@ class TestNoFalsePositives: assert result[1] == (Path("Fahrenheit 451 - Part 2.mp3"), "02") def test_1984(self): - files = [Path("1984 - Chapter 03.mp3"), Path("1984 - Chapter 01.mp3"), Path("1984 - Chapter 02.mp3")] + files = [ + Path("1984 - Chapter 03.mp3"), + Path("1984 - Chapter 01.mp3"), + Path("1984 - Chapter 02.mp3"), + ] result = assign_part_numbers(files) - assert [r[0].name for r in result] == ["1984 - Chapter 01.mp3", "1984 - Chapter 02.mp3", "1984 - Chapter 03.mp3"] + assert [r[0].name for r in result] == [ + "1984 - Chapter 01.mp3", + "1984 - Chapter 02.mp3", + "1984 - Chapter 03.mp3", + ] def test_catch_22(self): files = [Path("Catch-22 Part 2.mp3"), Path("Catch-22 Part 1.mp3")] @@ -142,7 +187,6 @@ class TestNoFalsePositives: class TestEdgeCases: - def test_identical_filenames_different_dirs(self): files = [Path("/dir2/track.mp3"), Path("/dir1/track.mp3")] result = assign_part_numbers(files) diff --git a/tests/core/test_per_user_downloads.py b/tests/core/test_per_user_downloads.py index dc8fb8e..40f0e89 100644 --- a/tests/core/test_per_user_downloads.py +++ b/tests/core/test_per_user_downloads.py @@ -166,7 +166,9 @@ class TestPerUserDestination: ) monkeypatch.setattr( "shelfmark.download.postprocess.destination.get_source", - lambda _: type("Source", (), {"get_destination_override": staticmethod(lambda task: None)})(), + lambda _: type( + "Source", (), {"get_destination_override": staticmethod(lambda task: None)} + )(), ) from shelfmark.download.postprocess.destination import get_final_destination @@ -199,7 +201,9 @@ class TestPerUserDestination: ) monkeypatch.setattr( "shelfmark.download.postprocess.destination.get_source", - lambda _: type("Source", (), {"get_destination_override": staticmethod(lambda task: None)})(), + lambda _: type( + "Source", (), {"get_destination_override": staticmethod(lambda task: None)} + )(), ) from shelfmark.download.postprocess.destination import get_final_destination @@ -354,3 +358,17 @@ class TestTaskToDictUsername: ) result = _task_to_dict(task) assert result["username"] is None + + def test_task_to_dict_prefers_current_queue_status(self): + """Serialized task status should reflect the queue bucket being emitted.""" + from shelfmark.download.orchestrator import _task_to_dict + + task = DownloadTask( + task_id="book1", + source="direct_download", + title="Test Book", + status=QueueStatus.QUEUED, + ) + + result = _task_to_dict(task, current_status=QueueStatus.COMPLETE) + assert result["status"] == QueueStatus.COMPLETE.value diff --git a/tests/core/test_processing_integration.py b/tests/core/test_processing_integration.py index 2c8bdf0..dc8c28f 100644 --- a/tests/core/test_processing_integration.py +++ b/tests/core/test_processing_integration.py @@ -47,7 +47,9 @@ def _sync_config(mock_config, mock_core): def test_direct_download_rename_moves_file(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -69,8 +71,10 @@ def test_direct_download_rename_moves_file(tmp_path): statuses = [] status_cb = lambda status, message: statuses.append((status, message)) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config(ingest, organization="rename") mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -88,7 +92,9 @@ def test_direct_download_rename_moves_file(tmp_path): @pytest.mark.parametrize("source_kind", ["direct", "torrent"]) def test_original_name_rename_single_file_for_direct_and_torrent(tmp_path, source_kind: str): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" downloads = tmp_path / "downloads" @@ -111,8 +117,10 @@ def test_original_name_rename_single_file_for_direct_and_torrent(tmp_path, sourc original_download_path=str(input_path) if source_kind == "torrent" else None, ) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization="rename", @@ -137,7 +145,9 @@ def test_original_name_rename_single_file_for_direct_and_torrent(tmp_path, sourc def test_torrent_hardlink_preserves_source(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" ingest = tmp_path / "ingest" @@ -159,8 +169,10 @@ def test_torrent_hardlink_preserves_source(tmp_path): status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"), + ): mock_config.get = _build_config(ingest, organization="organize", hardlink=True) mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -175,7 +187,9 @@ def test_torrent_hardlink_preserves_source(tmp_path): def test_archive_extraction_rename_single_file_can_use_original_name(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -195,8 +209,10 @@ def test_archive_extraction_rename_single_file_can_use_original_name(tmp_path): search_mode=SearchMode.DIRECT, ) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization="rename", @@ -216,7 +232,9 @@ def test_archive_extraction_rename_single_file_can_use_original_name(tmp_path): def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" ingest = tmp_path / "ingest" @@ -239,8 +257,10 @@ def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_p status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"), + ): mock_config.get = _build_config( ingest, organization="none", @@ -268,7 +288,9 @@ def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_p def test_multifile_rename_ignores_template_even_with_original_name(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -290,8 +312,10 @@ def test_multifile_rename_ignores_template_even_with_original_name(tmp_path): search_mode=SearchMode.DIRECT, ) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization="rename", @@ -311,7 +335,9 @@ def test_multifile_rename_ignores_template_even_with_original_name(tmp_path): def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -337,9 +363,11 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa statuses = [] status_cb = lambda status, message: statuses.append((status, message)) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging), \ - patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False), + ): mock_config.get = _build_config(ingest, organization="none", hardlink=True) mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -361,7 +389,9 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_supported(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -389,9 +419,11 @@ def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_ status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging), \ - patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False), + ): mock_config.get = _build_config( ingest, organization="none", @@ -417,7 +449,9 @@ def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_ def test_torrent_copy_when_hardlink_disabled(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -441,8 +475,10 @@ def test_torrent_copy_when_hardlink_disabled(tmp_path): status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config(ingest, organization="none", hardlink=False) mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -459,7 +495,9 @@ def test_torrent_copy_when_hardlink_disabled(tmp_path): def test_archive_extraction_flow(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -481,8 +519,10 @@ def test_archive_extraction_flow(tmp_path): status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config(ingest, organization="rename") mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -496,7 +536,9 @@ def test_archive_extraction_flow(tmp_path): def test_archive_extraction_organize_creates_directories(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -518,8 +560,10 @@ def test_archive_extraction_organize_creates_directories(tmp_path): status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config(ingest, organization="organize") mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -533,8 +577,125 @@ def test_archive_extraction_organize_creates_directories(tmp_path): assert result_path.name == "Archive Test.epub" +def test_legacy_ingest_dir_still_routes_books_when_destination_is_unset(tmp_path): + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + temp_file = staging / "book.epub" + temp_file.write_text("content") + + task = DownloadTask( + task_id="legacy-ingest-book", + source="direct_download", + title="Legacy Book", + author="Legacy Author", + format="epub", + search_mode=SearchMode.DIRECT, + ) + + values = { + "DESTINATION": "", + "INGEST_DIR": str(ingest), + "DESTINATION_AUDIOBOOK": "", + "FILE_ORGANIZATION": "rename", + "FILE_ORGANIZATION_AUDIOBOOK": "rename", + "TEMPLATE_RENAME": "{Author} - {Title}", + "TEMPLATE_ORGANIZE": "{Author}/{Title}", + "TEMPLATE_AUDIOBOOK_RENAME": "{Author} - {Title}", + "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + "SUPPORTED_FORMATS": ["epub"], + "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3"], + "HARDLINK_TORRENTS": False, + "HARDLINK_TORRENTS_AUDIOBOOK": False, + } + + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: values.get(key, default) + ) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(temp_file, task, Event(), lambda *_args: None) + + assert result is not None + result_path = Path(result) + assert result_path.exists() + assert result_path.parent == ingest + assert result_path.name == "Legacy Author - Legacy Book.epub" + + +def test_legacy_ingest_dir_still_routes_audiobooks_when_destinations_are_unset(tmp_path): + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + temp_file = staging / "audio.mp3" + temp_file.write_text("content") + + task = DownloadTask( + task_id="legacy-ingest-audio", + source="direct_download", + title="Legacy Audio", + author="Legacy Narrator", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.DIRECT, + ) + + values = { + "DESTINATION": "", + "INGEST_DIR": str(ingest), + "DESTINATION_AUDIOBOOK": "", + "FILE_ORGANIZATION": "rename", + "FILE_ORGANIZATION_AUDIOBOOK": "rename", + "TEMPLATE_RENAME": "{Author} - {Title}", + "TEMPLATE_ORGANIZE": "{Author}/{Title}", + "TEMPLATE_AUDIOBOOK_RENAME": "{Author} - {Title}", + "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + "SUPPORTED_FORMATS": ["epub"], + "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3"], + "HARDLINK_TORRENTS": False, + "HARDLINK_TORRENTS_AUDIOBOOK": False, + } + + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: values.get(key, default) + ) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(temp_file, task, Event(), lambda *_args: None) + + assert result is not None + result_path = Path(result) + assert result_path.exists() + assert result_path.parent == ingest + assert result_path.name == "Legacy Narrator - Legacy Audio.mp3" + + def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -558,8 +719,10 @@ def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path): status_cb = lambda *_args: None - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config(ingest, organization="organize") mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -575,7 +738,9 @@ def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path): def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -614,9 +779,13 @@ def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path): "HARDLINK_TORRENTS_AUDIOBOOK": False, } - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging): - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: values.get(key, default)) + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: values.get(key, default) + ) mock_config.CUSTOM_SCRIPT = None _sync_config(mock_config, mock_config) @@ -629,7 +798,9 @@ def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path): def test_booklore_mode_uploads_and_cleans_staging(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" staging.mkdir() @@ -663,11 +834,15 @@ def test_booklore_mode_uploads_and_cleans_staging(tmp_path): "BOOKLORE_PATH_ID": 2, } - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), \ - patch("shelfmark.download.outputs.booklore.booklore_upload_file", side_effect=_upload_stub), \ - patch("shelfmark.config.env.TMP_DIR", staging): - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default)) + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), + patch("shelfmark.download.outputs.booklore.booklore_upload_file", side_effect=_upload_stub), + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default) + ) result = _post_process_download(temp_file, task, Event(), status_cb) @@ -679,7 +854,9 @@ def test_booklore_mode_uploads_and_cleans_staging(tmp_path): def test_booklore_mode_rejects_unsupported_files(tmp_path): - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" staging.mkdir() @@ -707,11 +884,15 @@ def test_booklore_mode_rejects_unsupported_files(tmp_path): "BOOKLORE_PATH_ID": 2, } - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.download.outputs.booklore.booklore_login") as mock_login, \ - patch("shelfmark.download.outputs.booklore.booklore_upload_file") as mock_upload, \ - patch("shelfmark.config.env.TMP_DIR", staging): - mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default)) + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.download.outputs.booklore.booklore_login") as mock_login, + patch("shelfmark.download.outputs.booklore.booklore_upload_file") as mock_upload, + patch("shelfmark.config.env.TMP_DIR", staging), + ): + mock_config.get = MagicMock( + side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default) + ) result = _post_process_download(temp_file, task, Event(), status_cb) @@ -730,7 +911,6 @@ def test_booklore_mode_rejects_unsupported_files(tmp_path): @pytest.mark.parametrize("input_kind", ["file", "directory", "archive"]) @pytest.mark.parametrize("source_kind", ["direct", "usenet"]) @pytest.mark.parametrize("content_kind", ["book", "audiobook"]) - def test_postprocess_folder_blackbox_matrix( tmp_path, source_kind: str, @@ -748,7 +928,9 @@ def test_postprocess_folder_blackbox_matrix( This intentionally avoids mocking internal pipeline helpers. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) staging = tmp_path / "staging" ingest = tmp_path / "ingest" @@ -802,7 +984,10 @@ def test_postprocess_folder_blackbox_matrix( supported_formats = [extension] if extension != "mp3" else ["epub"] supported_audiobook_formats = [extension] if extension == "mp3" else ["mp3"] - with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization=organization, @@ -844,7 +1029,6 @@ def test_postprocess_folder_blackbox_matrix( @pytest.mark.parametrize("organization", ["none", "organize"]) @pytest.mark.parametrize("hardlink_enabled", [False, True]) @pytest.mark.parametrize("same_filesystem", [True, False]) - def test_postprocess_torrent_blackbox_matrix( tmp_path, input_kind: str, @@ -867,7 +1051,9 @@ def test_postprocess_torrent_blackbox_matrix( - TMP workspace stays clean """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -911,9 +1097,13 @@ def test_postprocess_torrent_blackbox_matrix( original_download_path=str(input_path), ) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging), \ - patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + patch( + "shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem + ), + ): mock_config.get = _build_config( ingest, organization=organization, @@ -951,11 +1141,12 @@ def test_postprocess_torrent_blackbox_matrix( assert list(staging.iterdir()) == [] - def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path): """Custom script should run against the final imported file; external source must be preserved.""" - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -977,9 +1168,11 @@ def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path original_download_path=None, ) - with patch("shelfmark.core.config.config") as mock_config, \ - patch("shelfmark.config.env.TMP_DIR", staging), \ - patch("subprocess.run") as mock_run: + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + patch("subprocess.run") as mock_run, + ): mock_config.get = _build_config(ingest, organization="none") mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" _sync_config(mock_config, mock_config) @@ -1005,16 +1198,18 @@ def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path assert list(staging.iterdir()) == [] - @pytest.mark.parametrize("content_kind", ["book", "audiobook"]) - -def test_external_directory_multiple_archives_extracts_all_and_keeps_source(tmp_path, content_kind: str): +def test_external_directory_multiple_archives_extracts_all_and_keeps_source( + tmp_path, content_kind: str +): """External directories with only archives should extract into TMP and not touch source archives.""" # This case is meant to model a usenet-like client "completed" directory containing # one or more archive releases, where Shelfmark must treat the source as read-only. - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -1056,7 +1251,10 @@ def test_external_directory_multiple_archives_extracts_all_and_keeps_source(tmp_ original_download_path=None, ) - with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization="none", @@ -1083,15 +1281,18 @@ def test_external_directory_multiple_archives_extracts_all_and_keeps_source(tmp_ @pytest.mark.parametrize("content_kind", ["book", "audiobook"]) - -def test_external_directory_prefers_files_over_archives_and_keeps_source(tmp_path, content_kind: str): +def test_external_directory_prefers_files_over_archives_and_keeps_source( + tmp_path, content_kind: str +): """If supported files exist in an external directory, archives are ignored. This models a usenet-like client directory that contains both a usable file and an archive. Shelfmark should import the usable file and leave the archive alone. """ - from shelfmark.download.postprocess.router import post_process_download as _post_process_download + from shelfmark.download.postprocess.router import ( + post_process_download as _post_process_download, + ) downloads = tmp_path / "downloads" staging = tmp_path / "staging" @@ -1132,7 +1333,10 @@ def test_external_directory_prefers_files_over_archives_and_keeps_source(tmp_pat original_download_path=None, ) - with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging): + with ( + patch("shelfmark.core.config.config") as mock_config, + patch("shelfmark.config.env.TMP_DIR", staging), + ): mock_config.get = _build_config( ingest, organization="none", diff --git a/tests/core/test_releases_api_direct_provider.py b/tests/core/test_releases_api_direct_provider.py index 140ccfd..d2b9d1d 100644 --- a/tests/core/test_releases_api_direct_provider.py +++ b/tests/core/test_releases_api_direct_provider.py @@ -36,7 +36,7 @@ def client(main_module): class _FakeDirectSource: last_search_type = "title_author" - def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001 + def get_record(self, record_id, *, fetch_download_count=True): assert record_id == "md5-abc" assert fetch_download_count is True return SimpleNamespace( @@ -52,7 +52,7 @@ class _FakeDirectSource: source_url=None, ) - def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001 + def search(self, book, plan, expand_search=False, content_type="ebook"): assert book.provider == "direct_download" assert book.provider_id == "md5-abc" assert book.title == "The Gun Seller" @@ -86,7 +86,9 @@ def test_releases_accepts_direct_download_provider(main_module, client): fake_direct_source = _FakeDirectSource() with patch.object(main_module, "get_auth_mode", return_value="none"): - with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source: + with patch( + "shelfmark.release_sources.get_source", return_value=fake_direct_source + ) as mock_get_source: with patch( "shelfmark.release_sources.list_available_sources", side_effect=AssertionError("list_available_sources should not be called"), @@ -114,13 +116,15 @@ def test_releases_accepts_direct_download_provider(main_module, client): def test_releases_direct_provider_returns_404_when_book_missing(main_module, client): class _MissingDirectSource: - def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001 + def get_record(self, record_id, *, fetch_download_count=True): assert record_id == "missing-md5" assert fetch_download_count is True return None with patch.object(main_module, "get_auth_mode", return_value="none"): - with patch("shelfmark.release_sources.get_source", return_value=_MissingDirectSource()) as mock_get_source: + with patch( + "shelfmark.release_sources.get_source", return_value=_MissingDirectSource() + ) as mock_get_source: resp = client.get( "/api/releases", query_string={ @@ -138,7 +142,7 @@ def test_releases_accepts_direct_source_query_mode(main_module, client): class _QueryDirectSource: last_search_type = "manual" - def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001 + def search(self, book, plan, expand_search=False, content_type="ebook"): assert book.provider == "manual" assert book.title == "Pride and Prejudice" assert plan.source_filters is not None @@ -175,7 +179,9 @@ def test_releases_accepts_direct_source_query_mode(main_module, client): ) with patch.object(main_module, "get_auth_mode", return_value="none"): - with patch("shelfmark.release_sources.get_source", return_value=_QueryDirectSource()) as mock_get_source: + with patch( + "shelfmark.release_sources.get_source", return_value=_QueryDirectSource() + ) as mock_get_source: resp = client.get( "/api/releases", query_string={ @@ -200,7 +206,9 @@ def test_release_source_record_endpoint_returns_generic_browse_record(main_modul fake_direct_source = _FakeDirectSource() with patch.object(main_module, "get_auth_mode", return_value="none"): - with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source: + with patch( + "shelfmark.release_sources.get_source", return_value=fake_direct_source + ) as mock_get_source: resp = client.get("/api/release-sources/direct_download/records/md5-abc") assert resp.status_code == 200 @@ -213,8 +221,10 @@ def test_release_source_record_endpoint_returns_generic_browse_record(main_modul def test_releases_direct_provider_returns_503_when_source_unavailable(main_module, client): class _UnavailableDirectSource: - def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001 - raise SourceUnavailableError("Unable to reach download source. Network restricted or mirrors are blocked.") + def get_record(self, record_id, *, fetch_download_count=True): + raise SourceUnavailableError( + "Unable to reach download source. Network restricted or mirrors are blocked." + ) with patch.object(main_module, "get_auth_mode", return_value="none"): with patch("shelfmark.release_sources.get_source", return_value=_UnavailableDirectSource()): @@ -234,8 +244,10 @@ def test_releases_direct_provider_returns_503_when_source_unavailable(main_modul def test_release_source_record_endpoint_returns_503_when_source_unavailable(main_module, client): class _UnavailableDirectSource: - def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001 - raise SourceUnavailableError("Unable to reach download source. Network restricted or mirrors are blocked.") + def get_record(self, record_id, *, fetch_download_count=True): + raise SourceUnavailableError( + "Unable to reach download source. Network restricted or mirrors are blocked." + ) with patch.object(main_module, "get_auth_mode", return_value="none"): with patch("shelfmark.release_sources.get_source", return_value=_UnavailableDirectSource()): diff --git a/tests/core/test_request_policy.py b/tests/core/test_request_policy.py index f1c4651..a5c87a2 100644 --- a/tests/core/test_request_policy.py +++ b/tests/core/test_request_policy.py @@ -70,7 +70,9 @@ def test_merge_request_policy_settings_overlays_user_rules_on_global_rules(): merged = merge_request_policy_settings(global_settings, user_settings) - assert sorted(merged["REQUEST_POLICY_RULES"], key=lambda row: (row["source"], row["content_type"])) == [ + assert sorted( + merged["REQUEST_POLICY_RULES"], key=lambda row: (row["source"], row["content_type"]) + ) == [ {"source": "direct_download", "content_type": "ebook", "mode": "blocked"}, {"source": "prowlarr", "content_type": "ebook", "mode": "request_release"}, ] @@ -120,29 +122,41 @@ def test_resolve_policy_mode_uses_wildcard_precedence(): } # (prowlarr, ebook) exact match → download - assert resolve_policy_mode( - source="prowlarr", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.DOWNLOAD + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.DOWNLOAD + ) # (prowlarr, audiobook) → matches (prowlarr, *) → request_release - assert resolve_policy_mode( - source="prowlarr", - content_type="audiobook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="audiobook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) # (irc, ebook) → matches (*, ebook) → request_release - assert resolve_policy_mode( - source="irc", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE + assert ( + resolve_policy_mode( + source="irc", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) # (irc, audiobook) → matches (*, *) → blocked - assert resolve_policy_mode( - source="irc", - content_type="audiobook", - global_settings=settings, - ) == PolicyMode.BLOCKED + assert ( + resolve_policy_mode( + source="irc", + content_type="audiobook", + global_settings=settings, + ) + == PolicyMode.BLOCKED + ) def test_resolve_policy_mode_uses_content_default_when_no_rule_matches(): @@ -152,16 +166,22 @@ def test_resolve_policy_mode_uses_content_default_when_no_rule_matches(): "REQUEST_POLICY_RULES": [], } - assert resolve_policy_mode( - source="direct_download", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.DOWNLOAD - assert resolve_policy_mode( - source="direct_download", - content_type="audiobook", - global_settings=settings, - ) == PolicyMode.BLOCKED + assert ( + resolve_policy_mode( + source="direct_download", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.DOWNLOAD + ) + assert ( + resolve_policy_mode( + source="direct_download", + content_type="audiobook", + global_settings=settings, + ) + == PolicyMode.BLOCKED + ) def test_resolve_policy_mode_caps_at_content_type_default_ceiling(): @@ -176,23 +196,32 @@ def test_resolve_policy_mode_caps_at_content_type_default_ceiling(): } # prowlarr/ebook rule says download, but ceiling is request_release → capped - assert resolve_policy_mode( - source="prowlarr", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) # irc/ebook rule says blocked, which is more restrictive than ceiling → stays blocked - assert resolve_policy_mode( - source="irc", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.BLOCKED + assert ( + resolve_policy_mode( + source="irc", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.BLOCKED + ) # no rule for direct_download → falls to ceiling - assert resolve_policy_mode( - source="direct_download", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE + assert ( + resolve_policy_mode( + source="direct_download", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) def test_resolve_policy_mode_request_book_ceiling_overrides_all_rules(): @@ -207,23 +236,32 @@ def test_resolve_policy_mode_request_book_ceiling_overrides_all_rules(): } # Prowlarr rule tries to upgrade beyond request_book → capped - assert resolve_policy_mode( - source="prowlarr", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_BOOK + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_BOOK + ) # Direct-download requests are concrete releases, so request_book normalizes to request_release. - assert resolve_policy_mode( - source="direct_download", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE + assert ( + resolve_policy_mode( + source="direct_download", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) # audiobook default is blocked → even more restrictive ceiling - assert resolve_policy_mode( - source="prowlarr", - content_type="audiobook", - global_settings=settings, - ) == PolicyMode.BLOCKED + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="audiobook", + global_settings=settings, + ) + == PolicyMode.BLOCKED + ) def test_resolve_policy_mode_falls_back_to_request_book_when_unset(): @@ -233,16 +271,22 @@ def test_resolve_policy_mode_falls_back_to_request_book_when_unset(): "REQUEST_POLICY_RULES": [], } - assert resolve_policy_mode( - source="direct_download", - content_type="ebook", - global_settings=settings, - ) == PolicyMode.REQUEST_RELEASE - assert resolve_policy_mode( - source="prowlarr", - content_type="audiobook", - global_settings=settings, - ) == PolicyMode.REQUEST_BOOK + assert ( + resolve_policy_mode( + source="direct_download", + content_type="ebook", + global_settings=settings, + ) + == PolicyMode.REQUEST_RELEASE + ) + assert ( + resolve_policy_mode( + source="prowlarr", + content_type="audiobook", + global_settings=settings, + ) + == PolicyMode.REQUEST_BOOK + ) def test_resolve_policy_mode_ignores_invalid_rule_rows(): diff --git a/tests/core/test_request_routes_api.py b/tests/core/test_request_routes_api.py index b6fdabf..30e27e8 100644 --- a/tests/core/test_request_routes_api.py +++ b/tests/core/test_request_routes_api.py @@ -4,9 +4,10 @@ from __future__ import annotations import importlib import uuid -from unittest.mock import ANY, patch +from unittest.mock import patch import pytest + from shelfmark.core.notifications import NotificationEvent @@ -59,8 +60,16 @@ def _policy( } +def _assert_emit_call(mock_emit, index: int, event: str, payload: dict, room: str) -> None: + call = mock_emit.call_args_list[index] + assert call.args == (event, payload) + assert call.kwargs == {"to": room} + + class TestDownloadPolicyGuards: - def test_release_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client): + def test_release_download_endpoint_blocks_before_queue_when_policy_requires_request( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -70,11 +79,18 @@ class TestDownloadPolicyGuards: "load_users_request_policy_settings", return_value=_policy(default_ebook="request_release"), ): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="request_release")): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=_policy(default_ebook="request_release"), + ): with patch.object(main_module.backend, "queue_release") as mock_queue_release: resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"}, + json={ + "source": "direct_download", + "source_id": "book-123", + "search_mode": "direct", + }, ) assert resp.status_code == 403 @@ -82,7 +98,9 @@ class TestDownloadPolicyGuards: assert resp.json["required_mode"] == "request_release" mock_queue_release.assert_not_called() - def test_release_download_endpoint_blocks_before_queue_when_policy_blocked(self, main_module, client): + def test_release_download_endpoint_blocks_before_queue_when_policy_blocked( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -92,11 +110,18 @@ class TestDownloadPolicyGuards: "load_users_request_policy_settings", return_value=_policy(default_ebook="blocked"), ): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="blocked")): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=_policy(default_ebook="blocked"), + ): with patch.object(main_module.backend, "queue_release") as mock_queue_release: resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "rel-1", "content_type": "ebook"}, + json={ + "source": "direct_download", + "source_id": "rel-1", + "content_type": "ebook", + }, ) assert resp.status_code == 403 @@ -114,11 +139,20 @@ class TestDownloadPolicyGuards: "load_users_request_policy_settings", return_value=_policy(default_ebook="blocked"), ): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="blocked")): - with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue_release: + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=_policy(default_ebook="blocked"), + ): + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ) as mock_queue_release: resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"}, + json={ + "source": "direct_download", + "source_id": "book-123", + "search_mode": "direct", + }, ) assert resp.status_code == 200 @@ -132,11 +166,20 @@ class TestDownloadPolicyGuards: "load_users_request_policy_settings", return_value=_policy(default_ebook="blocked"), ): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="blocked")): - with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue_release: + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=_policy(default_ebook="blocked"), + ): + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ) as mock_queue_release: resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"}, + json={ + "source": "direct_download", + "source_id": "book-123", + "search_mode": "direct", + }, ) assert resp.status_code == 200 @@ -158,8 +201,13 @@ class TestRequestRoutes: policy = _policy(default_ebook="request_release") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.get("/api/request-policy") assert resp.status_code == 200 @@ -167,14 +215,21 @@ class TestRequestRoutes: assert resp.json["defaults"]["ebook"] == "request_release" assert "source_modes" in resp.json - def test_request_policy_endpoint_normalizes_direct_request_book_to_request_release(self, main_module, client): + def test_request_policy_endpoint_normalizes_direct_request_book_to_request_release( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch( "shelfmark.core.request_routes.get_source_content_type_capabilities", return_value={"direct_download": {"ebook"}}, @@ -208,8 +263,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=payload) list_resp = client.get("/api/requests") @@ -262,11 +322,22 @@ class TestRequestRoutes: return True, None with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): - with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin: - with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user: + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + with patch.object( + main_module.backend, "queue_release", side_effect=fake_queue_release + ): + with patch( + "shelfmark.core.request_routes.notify_admin" + ) as mock_notify_admin: + with patch( + "shelfmark.core.request_routes.notify_user" + ) as mock_notify_user: resp = client.post("/api/requests", json=payload) assert resp.status_code == 200 @@ -283,7 +354,9 @@ class TestRequestRoutes: mock_notify_admin.assert_not_called() mock_notify_user.assert_not_called() - def test_batch_download_policy_queues_releases_without_creating_requests(self, main_module, client): + def test_batch_download_policy_queues_releases_without_creating_requests( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) policy = _policy(default_ebook="download") @@ -336,10 +409,19 @@ class TestRequestRoutes: return True, None with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): - with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin: + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + with patch.object( + main_module.backend, "queue_release", side_effect=fake_queue_release + ): + with patch( + "shelfmark.core.request_routes.notify_admin" + ) as mock_notify_admin: resp = client.post("/api/requests/batch", json={"requests": payloads}) assert resp.status_code == 200 @@ -378,8 +460,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -388,6 +475,77 @@ class TestRequestRoutes: assert created is not None assert created["user_id"] == target_user["id"] + def test_non_admin_cannot_create_request_on_behalf_of_another_user(self, main_module, client): + user = _create_user(main_module, prefix="reader") + target_user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + policy = _policy(default_ebook="request_book") + + payload = { + "book_data": { + "title": "Shared Request", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "shared-2", + }, + "context": { + "source": "*", + "content_type": "ebook", + "request_level": "book", + }, + "on_behalf_of_user_id": target_user["id"], + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post("/api/requests", json=payload) + + assert resp.status_code == 403 + assert resp.json["error"] == "Admin required" + assert main_module.user_db.list_requests(user_id=user["id"]) == [] + assert main_module.user_db.list_requests(user_id=target_user["id"]) == [] + + def test_admin_on_behalf_of_unknown_user_returns_404(self, main_module, client): + admin = _create_user(main_module, prefix="admin", role="admin") + _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + policy = _policy(default_ebook="request_book") + + payload = { + "book_data": { + "title": "Missing Target", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "missing-target", + }, + "context": { + "source": "*", + "content_type": "ebook", + "request_level": "book", + }, + "on_behalf_of_user_id": 9_999_999, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post("/api/requests", json=payload) + + assert resp.status_code == 404 + assert resp.json["error"] == "User not found" + def test_batch_create_requests_is_atomic(self, main_module, client): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -409,8 +567,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post( "/api/requests/batch", json={"requests": [duplicate_request, duplicate_request]}, @@ -441,8 +604,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.ws_manager, "is_enabled", return_value=True): with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: resp = client.post("/api/requests", json=payload) @@ -451,14 +619,13 @@ class TestRequestRoutes: request_id = resp.json["id"] assert mock_emit.call_count == 2 - mock_emit.assert_any_call("new_request", ANY, to="admins") - mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}") - - emitted_payloads = {call.args[0]: call.args[1] for call in mock_emit.call_args_list} - assert emitted_payloads["new_request"]["request_id"] == request_id - assert emitted_payloads["new_request"]["status"] == "pending" - assert emitted_payloads["new_request"]["title"] == "Eventful Book" - assert emitted_payloads["request_update"]["request_id"] == request_id + expected_payload = { + "request_id": request_id, + "status": "pending", + "title": "Eventful Book", + } + _assert_emit_call(mock_emit, 0, "new_request", expected_payload, "admins") + _assert_emit_call(mock_emit, 1, "request_update", expected_payload, f"user_{user['id']}") def test_create_request_triggers_admin_notification(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -481,8 +648,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch("shelfmark.core.request_routes.notify_admin") as mock_notify: with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user: resp = client.post("/api/requests", json=payload) @@ -521,8 +693,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch( "shelfmark.core.request_routes.notify_admin", side_effect=RuntimeError("admin notification unavailable"), @@ -559,8 +736,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.ws_manager, "is_enabled", return_value=True): with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: create_resp = client.post("/api/requests", json=payload) @@ -574,8 +756,13 @@ class TestRequestRoutes: assert cancel_resp.json["status"] == "cancelled" assert mock_emit.call_count == 2 - mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}") - mock_emit.assert_any_call("request_update", ANY, to="admins") + expected_payload = { + "request_id": request_id, + "status": "cancelled", + "title": "Cancelable Book", + } + _assert_emit_call(mock_emit, 0, "request_update", expected_payload, f"user_{user['id']}") + _assert_emit_call(mock_emit, 1, "request_update", expected_payload, "admins") def test_create_request_level_payload_mismatch_returns_400(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -603,8 +790,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 400 @@ -631,8 +823,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): first_resp = client.post("/api/requests", json=payload) second_resp = client.post("/api/requests", json=payload) @@ -675,8 +872,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): first_resp = client.post("/api/requests", json=payload_1) second_resp = client.post("/api/requests", json=payload_2) @@ -706,8 +908,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -739,8 +946,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 403 @@ -775,8 +987,13 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -817,12 +1034,19 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) count_resp = client.get("/api/admin/requests/count") reject_resp = client.post( f"/api/admin/requests/{request_id}/reject", @@ -866,12 +1090,19 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) with patch.object(main_module.ws_manager, "is_enabled", return_value=True): with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: reject_resp = client.post( @@ -884,8 +1115,13 @@ class TestRequestRoutes: assert reject_resp.json["status"] == "rejected" assert mock_emit.call_count == 2 - mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}") - mock_emit.assert_any_call("request_update", ANY, to="admins") + expected_payload = { + "request_id": request_id, + "status": "rejected", + "title": "Reject Emit Book", + } + _assert_emit_call(mock_emit, 0, "request_update", expected_payload, f"user_{user['id']}") + _assert_emit_call(mock_emit, 1, "request_update", expected_payload, "admins") def test_admin_reject_triggers_admin_notification(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -909,12 +1145,19 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) with patch("shelfmark.core.request_routes.notify_admin") as mock_notify: with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user: reject_resp = client.post( @@ -972,13 +1215,22 @@ class TestRequestRoutes: return True, None with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", side_effect=fake_queue_release + ): fulfil_resp = client.post( f"/api/admin/requests/{request_id}/fulfil", json={"admin_note": "Approved"}, @@ -1017,13 +1269,22 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): with patch.object(main_module.ws_manager, "is_enabled", return_value=True): with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: fulfil_resp = client.post( @@ -1036,8 +1297,13 @@ class TestRequestRoutes: assert fulfil_resp.json["status"] == "fulfilled" assert mock_emit.call_count == 2 - mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}") - mock_emit.assert_any_call("request_update", ANY, to="admins") + expected_payload = { + "request_id": request_id, + "status": "fulfilled", + "title": "Fulfil Emit Book", + } + _assert_emit_call(mock_emit, 0, "request_update", expected_payload, f"user_{user['id']}") + _assert_emit_call(mock_emit, 1, "request_update", expected_payload, "admins") def test_admin_fulfil_triggers_admin_notification(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -1066,15 +1332,26 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): with patch("shelfmark.core.request_routes.notify_admin") as mock_notify: - with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user: + with patch( + "shelfmark.core.request_routes.notify_user" + ) as mock_notify_user: fulfil_resp = client.post( f"/api/admin/requests/{request_id}/fulfil", json={"admin_note": "Approved"}, @@ -1115,12 +1392,19 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={}) assert fulfil_resp.status_code == 400 @@ -1148,13 +1432,22 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue: + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ) as mock_queue: fulfil_resp = client.post( f"/api/admin/requests/{request_id}/fulfil", json={"manual_approval": True, "admin_note": "Added manually"}, @@ -1199,13 +1492,22 @@ class TestRequestRoutes: return True, None with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", side_effect=fake_queue_release + ): fulfil_resp = client.post( f"/api/admin/requests/{request_id}/fulfil", json={ @@ -1227,7 +1529,9 @@ class TestRequestRoutes: assert captured["user_id"] == user["id"] assert captured["username"] == user["username"] - def test_admin_fulfil_uses_real_queue_and_preserves_requesting_identity(self, main_module, client): + def test_admin_fulfil_uses_real_queue_and_preserves_requesting_identity( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") other_user = _create_user(main_module, prefix="reader") admin = _create_user(main_module, prefix="admin", role="admin") @@ -1256,12 +1560,19 @@ class TestRequestRoutes: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): create_resp = client.post("/api/requests", json=create_payload) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={}) assert fulfil_resp.status_code == 200 @@ -1284,8 +1595,13 @@ class TestRequestCreationEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", content_type="text/plain", data="garbage") assert resp.status_code == 400 @@ -1297,9 +1613,16 @@ class TestRequestCreationEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - resp = client.post("/api/requests", json={"context": {"source": "direct_download"}}) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post( + "/api/requests", json={"context": {"source": "direct_download"}} + ) assert resp.status_code == 400 assert "book_data must be an object" in resp.json["error"] @@ -1310,12 +1633,25 @@ class TestRequestCreationEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - resp = client.post("/api/requests", json={ - "context": "not-a-dict", - "book_data": {"title": "X", "author": "Y", "provider": "z", "provider_id": "1"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post( + "/api/requests", + json={ + "context": "not-a-dict", + "book_data": { + "title": "X", + "author": "Y", + "provider": "z", + "provider_id": "1", + }, + }, + ) assert resp.status_code == 400 assert "context must be an object" in resp.json["error"] @@ -1326,12 +1662,24 @@ class TestRequestCreationEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - resp = client.post("/api/requests", json={ - "book_data": {"title": "Only a title"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post( + "/api/requests", + json={ + "book_data": {"title": "Only a title"}, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) assert resp.status_code == 400 assert "missing required field" in resp.json["error"] @@ -1350,12 +1698,21 @@ class TestRequestCreationEdgeCases: "provider_id": "ol-big", "description": "x" * 12000, }, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 400 @@ -1367,12 +1724,29 @@ class TestRequestCreationEdgeCases: policy = _policy(requests_enabled=False, default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - resp = client.post("/api/requests", json={ - "book_data": {"title": "T", "author": "A", "provider": "p", "provider_id": "1"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "T", + "author": "A", + "provider": "p", + "provider_id": "1", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "requests_unavailable" @@ -1398,8 +1772,13 @@ class TestRequestCreationEdgeCases: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.backend, "queue_release") as mock_queue_release: resp = client.post("/api/requests", json=payload) @@ -1415,12 +1794,30 @@ class TestRequestCreationEdgeCases: policy = _policy(default_ebook="blocked") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - resp = client.post("/api/requests", json={ - "book_data": {"title": "T", "author": "A", "provider": "p", "provider_id": "1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "T", + "author": "A", + "provider": "p", + "provider_id": "1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "policy_blocked" @@ -1443,8 +1840,13 @@ class TestRequestCreationEdgeCases: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -1472,8 +1874,13 @@ class TestRequestCreationEdgeCases: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -1498,8 +1905,13 @@ class TestRequestCreationEdgeCases: } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -1514,10 +1926,13 @@ class TestRequestCreationEdgeCases: del sess["db_user_id"] with patch.object(main_module, "get_auth_mode", return_value="builtin"): - resp = client.post("/api/requests", json={ - "book_data": {"title": "T", "author": "A", "provider": "p", "provider_id": "1"}, - "context": {"source": "direct_download"}, - }) + resp = client.post( + "/api/requests", + json={ + "book_data": {"title": "T", "author": "A", "provider": "p", "provider_id": "1"}, + "context": {"source": "direct_download"}, + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "user_identity_unavailable" @@ -1535,13 +1950,22 @@ class TestRequestCreationEdgeCases: "provider": "hardcover", "provider_id": "hc-ab", }, - "context": {"source": "prowlarr", "content_type": "audiobook", "request_level": "release"}, + "context": { + "source": "prowlarr", + "content_type": "audiobook", + "request_level": "release", + }, "release_data": {"source": "prowlarr", "source_id": "ab-1", "title": "AB.m4b"}, } with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -1563,7 +1987,11 @@ class TestRequestListAndFilterEdgeCases: "provider": "openlibrary", "provider_id": f"ol-seed-{uuid.uuid4().hex[:6]}", }, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, } resp = client.post("/api/requests", json=payload) assert resp.status_code == 201 @@ -1582,13 +2010,18 @@ class TestRequestListAndFilterEdgeCases: def test_list_requests_with_status_filter(self, main_module, client): user = _create_user(main_module, prefix="reader") - admin = _create_user(main_module, prefix="admin", role="admin") + _create_user(main_module, prefix="admin", role="admin") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): ids = self._seed_requests(main_module, client, user, policy, count=3) # Cancel the first request. @@ -1613,8 +2046,13 @@ class TestRequestListAndFilterEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): self._seed_requests(main_module, client, user, policy, count=5) page1 = client.get("/api/requests?limit=2&offset=0") @@ -1637,27 +2075,64 @@ class TestRequestListAndFilterEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): # Alice creates a request. - _set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False) - client.post("/api/requests", json={ - "book_data": {"title": "Alice Book", "author": "A", "provider": "p", "provider_id": "a1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + _set_session( + client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False + ) + client.post( + "/api/requests", + json={ + "book_data": { + "title": "Alice Book", + "author": "A", + "provider": "p", + "provider_id": "a1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) # Bob creates a request. - _set_session(client, user_id=bob["username"], db_user_id=bob["id"], is_admin=False) - client.post("/api/requests", json={ - "book_data": {"title": "Bob Book", "author": "B", "provider": "p", "provider_id": "b1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + _set_session( + client, user_id=bob["username"], db_user_id=bob["id"], is_admin=False + ) + client.post( + "/api/requests", + json={ + "book_data": { + "title": "Bob Book", + "author": "B", + "provider": "p", + "provider_id": "b1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) # Bob lists — should only see his. bob_list = client.get("/api/requests") # Alice lists — should only see hers. - _set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False) + _set_session( + client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False + ) alice_list = client.get("/api/requests") assert len(bob_list.json) == 1 @@ -1672,20 +2147,43 @@ class TestRequestListAndFilterEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) - client.post("/api/requests", json={ - "book_data": {"title": "Admin View", "author": "AV", "provider": "p", "provider_id": "av1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + _set_session( + client, user_id=user["username"], db_user_id=user["id"], is_admin=False + ) + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Admin View", + "author": "AV", + "provider": "p", + "provider_id": "av1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) + request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) resp = client.get("/api/admin/requests") assert resp.status_code == 200 - matching = [r for r in resp.json if r["book_data"]["title"] == "Admin View"] - assert len(matching) >= 1 + matching = [r for r in resp.json if r["id"] == request_id] + assert len(matching) == 1 assert matching[0]["username"] == user["username"] def test_admin_list_with_status_filter(self, main_module, client): @@ -1694,16 +2192,38 @@ class TestRequestListAndFilterEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) - create_resp = client.post("/api/requests", json={ - "book_data": {"title": f"FilterTest-{uuid.uuid4().hex[:6]}", "author": "FT", "provider": "p", "provider_id": f"ft-{uuid.uuid4().hex[:6]}", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + _set_session( + client, user_id=user["username"], db_user_id=user["id"], is_admin=False + ) + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": f"FilterTest-{uuid.uuid4().hex[:6]}", + "author": "FT", + "provider": "p", + "provider_id": f"ft-{uuid.uuid4().hex[:6]}", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) client.post(f"/api/admin/requests/{request_id}/reject", json={}) pending_resp = client.get("/api/admin/requests?status=pending") @@ -1733,16 +2253,38 @@ class TestCancelEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - _set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False) - create_resp = client.post("/api/requests", json={ - "book_data": {"title": "Alice Only", "author": "A", "provider": "p", "provider_id": "ao1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + _set_session( + client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False + ) + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Alice Only", + "author": "A", + "provider": "p", + "provider_id": "ao1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) request_id = create_resp.json["id"] - _set_session(client, user_id=bob["username"], db_user_id=bob["id"], is_admin=False) + _set_session( + client, user_id=bob["username"], db_user_id=bob["id"], is_admin=False + ) cancel_resp = client.delete(f"/api/requests/{request_id}") assert cancel_resp.status_code == 403 @@ -1753,12 +2295,30 @@ class TestCancelEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - create_resp = client.post("/api/requests", json={ - "book_data": {"title": "Cancel Twice", "author": "CT", "provider": "p", "provider_id": "ct1", "content_type": "ebook"}, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Cancel Twice", + "author": "CT", + "provider": "p", + "provider_id": "ct1", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, + }, + ) request_id = create_resp.json["id"] first = client.delete(f"/api/requests/{request_id}") @@ -1777,9 +2337,12 @@ class TestAdminFulfilEdgeCases: _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - resp = client.post("/api/admin/requests/99999/fulfil", json={ - "release_data": {"source": "dd", "source_id": "r1", "title": "f.epub"}, - }) + resp = client.post( + "/api/admin/requests/99999/fulfil", + json={ + "release_data": {"source": "dd", "source_id": "r1", "title": "f.epub"}, + }, + ) assert resp.status_code == 404 @@ -1791,18 +2354,46 @@ class TestAdminFulfilEdgeCases: _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - create_resp = client.post("/api/requests", json={ - "book_data": {"title": "Queue Fail", "author": "QF", "provider": "p", "provider_id": "qf1", "content_type": "ebook"}, - "context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"}, - "release_data": {"source": "prowlarr", "source_id": "qf-r", "title": "QF.epub"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Queue Fail", + "author": "QF", + "provider": "p", + "provider_id": "qf1", + "content_type": "ebook", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "qf-r", + "title": "QF.epub", + }, + }, + ) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", return_value=(False, "Client offline")): - fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={}) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", return_value=(False, "Client offline") + ): + fulfil_resp = client.post( + f"/api/admin/requests/{request_id}/fulfil", json={} + ) assert fulfil_resp.status_code == 409 assert fulfil_resp.json["code"] == "queue_failed" @@ -1821,20 +2412,53 @@ class TestAdminFulfilEdgeCases: return True, None with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - create_resp = client.post("/api/requests", json={ - "book_data": {"title": "Override RD", "author": "OR", "provider": "p", "provider_id": "or1", "content_type": "ebook"}, - "context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"}, - "release_data": {"source": "prowlarr", "source_id": "original-r", "title": "Original.epub"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Override RD", + "author": "OR", + "provider": "p", + "provider_id": "or1", + "content_type": "ebook", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "original-r", + "title": "Original.epub", + }, + }, + ) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", side_effect=capture_queue): - fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={ - "release_data": {"source": "direct_download", "source_id": "better-r", "title": "Better.epub"}, - }) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", side_effect=capture_queue + ): + fulfil_resp = client.post( + f"/api/admin/requests/{request_id}/fulfil", + json={ + "release_data": { + "source": "direct_download", + "source_id": "better-r", + "title": "Better.epub", + }, + }, + ) assert fulfil_resp.status_code == 200 assert captured["release_data"]["source_id"] == "better-r" @@ -1861,22 +2485,40 @@ class TestAdminFulfilEdgeCases: _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - create_resp = client.post("/api/requests", json={ - "book_data": { - "title": "Manual Flag Validation", - "author": "QA", - "provider": "p", - "provider_id": "mf1", - "content_type": "ebook", + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Manual Flag Validation", + "author": "QA", + "provider": "p", + "provider_id": "mf1", + "content_type": "ebook", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "mf-r", + "title": "MF.epub", + }, }, - "context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"}, - "release_data": {"source": "prowlarr", "source_id": "mf-r", "title": "MF.epub"}, - }) + ) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) fulfil_resp = client.post( f"/api/admin/requests/{request_id}/fulfil", json={"manual_approval": "yes"}, @@ -1907,17 +2549,43 @@ class TestAdminRejectEdgeCases: _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - create_resp = client.post("/api/requests", json={ - "book_data": {"title": "Rej After Ful", "author": "RAF", "provider": "p", "provider_id": "raf1", "content_type": "ebook"}, - "context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"}, - "release_data": {"source": "prowlarr", "source_id": "raf-r", "title": "RAF.epub"}, - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + create_resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": "Rej After Ful", + "author": "RAF", + "provider": "p", + "provider_id": "raf1", + "content_type": "ebook", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "raf-r", + "title": "RAF.epub", + }, + }, + ) request_id = create_resp.json["id"] - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): client.post(f"/api/admin/requests/{request_id}/fulfil", json={}) reject_resp = client.post(f"/api/admin/requests/{request_id}/reject", json={}) @@ -1935,30 +2603,46 @@ class TestAdminCountEdgeCases: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + _set_session( + client, user_id=user["username"], db_user_id=user["id"], is_admin=False + ) # Create 3 requests. ids = [] - for i in range(3): - resp = client.post("/api/requests", json={ - "book_data": { - "title": f"Count Test {uuid.uuid4().hex[:6]}", - "author": "CT", - "provider": "p", - "provider_id": f"ct-{uuid.uuid4().hex[:6]}", - "content_type": "ebook", + for _i in range(3): + resp = client.post( + "/api/requests", + json={ + "book_data": { + "title": f"Count Test {uuid.uuid4().hex[:6]}", + "author": "CT", + "provider": "p", + "provider_id": f"ct-{uuid.uuid4().hex[:6]}", + "content_type": "ebook", + }, + "context": { + "source": "direct_download", + "content_type": "ebook", + "request_level": "book", + }, }, - "context": {"source": "direct_download", "content_type": "ebook", "request_level": "book"}, - }) + ) ids.append(resp.json["id"]) # Cancel one. client.delete(f"/api/requests/{ids[0]}") # Admin rejects one. - _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + _set_session( + client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True + ) client.post(f"/api/admin/requests/{ids[1]}/reject", json={}) count_resp = client.get("/api/admin/requests/count") @@ -1982,8 +2666,13 @@ class TestPolicyEndpointEdgeCases: policy = _policy(default_ebook="download") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.get("/api/request-policy") assert resp.status_code == 200 @@ -1995,11 +2684,18 @@ class TestPolicyEndpointEdgeCases: # Global says download, but user override sets request_release for ebook. global_policy = _policy(default_ebook="download", default_audiobook="download") - main_module.user_db.set_user_settings(user["id"], {"REQUEST_POLICY_DEFAULT_EBOOK": "request_release"}) + main_module.user_db.set_user_settings( + user["id"], {"REQUEST_POLICY_DEFAULT_EBOOK": "request_release"} + ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=global_policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=global_policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=global_policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=global_policy, + ): resp = client.get("/api/request-policy") assert resp.status_code == 200 @@ -2012,14 +2708,21 @@ class TestPolicyEndpointEdgeCases: assert resp.status_code == 401 - def test_policy_endpoint_includes_allow_notes_from_effective_settings(self, main_module, client): + def test_policy_endpoint_includes_allow_notes_from_effective_settings( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) policy = _policy(default_ebook="download", requests_allow_notes=False) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): resp = client.get("/api/request-policy") assert resp.status_code == 200 @@ -2033,8 +2736,13 @@ class TestPolicyEndpointEdgeCases: main_module.user_db.set_user_settings(user["id"], {"REQUESTS_ALLOW_NOTES": True}) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=global_policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=global_policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=global_policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=global_policy, + ): resp = client.get("/api/request-policy") assert resp.status_code == 200 @@ -2053,12 +2761,23 @@ class TestDownloadPolicyGuardsExtended: policy = _policy(requests_enabled=False, default_ebook="blocked") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "book-pass", "search_mode": "direct"}, + json={ + "source": "direct_download", + "source_id": "book-pass", + "search_mode": "direct", + }, ) assert resp.status_code == 200 @@ -2070,12 +2789,23 @@ class TestDownloadPolicyGuardsExtended: policy = _policy(default_ebook="download") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): resp = client.post( "/api/releases/download", - json={"source": "direct_download", "source_id": "book-free", "search_mode": "direct"}, + json={ + "source": "direct_download", + "source_id": "book-free", + "search_mode": "direct", + }, ) assert resp.status_code == 200 @@ -2087,35 +2817,53 @@ class TestDownloadPolicyGuardsExtended: policy = _policy(default_ebook="request_release") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.backend, "queue_release") as mock_queue: - resp = client.post("/api/releases/download", json={ - "source": "prowlarr", - "source_id": "rel-blocked", - "content_type": "ebook", - }) + resp = client.post( + "/api/releases/download", + json={ + "source": "prowlarr", + "source_id": "rel-blocked", + "content_type": "ebook", + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "policy_requires_request" assert resp.json["required_mode"] == "request_release" mock_queue.assert_not_called() - def test_release_download_infers_audiobook_type_from_format_when_content_type_missing(self, main_module, client): + def test_release_download_infers_audiobook_type_from_format_when_content_type_missing( + self, main_module, client + ): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) policy = _policy(default_ebook="download", default_audiobook="blocked") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.backend, "queue_release") as mock_queue: - resp = client.post("/api/releases/download", json={ - "source": "prowlarr", - "source_id": "audio-rel", - "title": "Some Audio [m4b]", - "format": "m4b", - }) + resp = client.post( + "/api/releases/download", + json={ + "source": "prowlarr", + "source_id": "audio-rel", + "title": "Some Audio [m4b]", + "format": "m4b", + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "policy_blocked" @@ -2128,14 +2876,22 @@ class TestDownloadPolicyGuardsExtended: policy = _policy(default_ebook="request_book") with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.backend, "queue_release") as mock_queue: - resp = client.post("/api/releases/download", json={ - "source": "direct_download", - "source_id": "rel-rbook", - "content_type": "ebook", - }) + resp = client.post( + "/api/releases/download", + json={ + "source": "direct_download", + "source_id": "rel-rbook", + "content_type": "ebook", + }, + ) assert resp.status_code == 403 assert resp.json["code"] == "policy_requires_request" @@ -2152,23 +2908,36 @@ class TestDownloadPolicyGuardsExtended: ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object( + main_module, "load_users_request_policy_settings", return_value=policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=policy, + ): with patch.object(main_module.backend, "queue_release") as mock_queue: # Prowlarr should be blocked. - prowlarr_resp = client.post("/api/releases/download", json={ - "source": "prowlarr", - "source_id": "prowlarr-rel", - "content_type": "ebook", - }) + prowlarr_resp = client.post( + "/api/releases/download", + json={ + "source": "prowlarr", + "source_id": "prowlarr-rel", + "content_type": "ebook", + }, + ) - with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue_dd: + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ) as mock_queue_dd: # DD should still be allowed. - dd_resp = client.post("/api/releases/download", json={ - "source": "direct_download", - "source_id": "dd-rel", - "content_type": "ebook", - }) + dd_resp = client.post( + "/api/releases/download", + json={ + "source": "direct_download", + "source_id": "dd-rel", + "content_type": "ebook", + }, + ) assert prowlarr_resp.status_code == 403 assert prowlarr_resp.json["code"] == "policy_blocked" @@ -2187,20 +2956,33 @@ class TestDownloadPolicyGuardsExtended: rules=[{"source": "prowlarr", "content_type": "*", "mode": "blocked"}], ) # User override: unblock prowlarr. - main_module.user_db.set_user_settings(user["id"], { - "REQUEST_POLICY_RULES": [ - {"source": "prowlarr", "content_type": "*", "mode": "download"}, - ], - }) + main_module.user_db.set_user_settings( + user["id"], + { + "REQUEST_POLICY_RULES": [ + {"source": "prowlarr", "content_type": "*", "mode": "download"}, + ], + }, + ) with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "load_users_request_policy_settings", return_value=global_policy): - with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=global_policy): - with patch.object(main_module.backend, "queue_release", return_value=(True, None)): - resp = client.post("/api/releases/download", json={ - "source": "prowlarr", - "source_id": "prowlarr-unlocked", - "content_type": "ebook", - }) + with patch.object( + main_module, "load_users_request_policy_settings", return_value=global_policy + ): + with patch( + "shelfmark.core.request_routes.load_users_request_policy_settings", + return_value=global_policy, + ): + with patch.object( + main_module.backend, "queue_release", return_value=(True, None) + ): + resp = client.post( + "/api/releases/download", + json={ + "source": "prowlarr", + "source_id": "prowlarr-unlocked", + "content_type": "ebook", + }, + ) assert resp.status_code == 200 diff --git a/tests/core/test_requests_service.py b/tests/core/test_requests_service.py index 132ee94..e3ed702 100644 --- a/tests/core/test_requests_service.py +++ b/tests/core/test_requests_service.py @@ -20,9 +20,10 @@ from shelfmark.core.requests_service import ( RequestServiceError, cancel_request, create_request, + create_requests, fulfil_request, - reopen_failed_request, reject_request, + reopen_failed_request, sync_delivery_states_from_queue_status, ) from shelfmark.core.user_db import UserDB @@ -169,6 +170,25 @@ def test_create_request_rejects_duplicate_pending(user_db): ) +def test_create_requests_rejects_duplicate_entries_and_is_atomic(user_db): + user = user_db.create_user(username="alice") + duplicate_request = { + "user_id": user["id"], + "source_hint": "direct_download", + "content_type": "ebook", + "request_level": "book", + "policy_mode": "request_book", + "book_data": _book_data(), + } + + with pytest.raises(RequestServiceError) as exc_info: + create_requests(user_db, requests=[duplicate_request, duplicate_request]) + + assert exc_info.value.status_code == 409 + assert exc_info.value.code == "duplicate_pending_request" + assert user_db.list_requests(user_id=user["id"]) == [] + + def test_create_request_rejects_when_max_pending_limit_reached(user_db): user = user_db.create_user(username="alice") @@ -743,6 +763,43 @@ def test_sync_delivery_states_reopens_fulfilled_request_when_error_is_not_retrya assert refreshed["last_failure_reason"] == "Staged retry source no longer exists" +def test_sync_delivery_states_preserves_retryable_error_state(user_db): + user = user_db.create_user(username="alice") + fulfilled_request = user_db.create_request( + user_id=user["id"], + source_hint="prowlarr", + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data=_book_data(), + release_data={"source": "prowlarr", "source_id": "retryable-rel", "title": "Retryable"}, + status="fulfilled", + delivery_state="queued", + ) + + updated = sync_delivery_states_from_queue_status( + user_db, + queue_status={ + "error": { + "retryable-rel": { + "id": "retryable-rel", + "request_id": fulfilled_request["id"], + "retry_available": True, + "status_message": "Temporary daemon failure", + }, + }, + }, + user_id=user["id"], + ) + + assert [row["id"] for row in updated] == [fulfilled_request["id"]] + refreshed = user_db.get_request(fulfilled_request["id"]) + assert refreshed["status"] == "fulfilled" + assert refreshed["delivery_state"] == "error" + assert refreshed["delivery_updated_at"] is not None + assert refreshed["last_failure_reason"] is None + + # --------------------------------------------------------------------------- # book_data validation # --------------------------------------------------------------------------- @@ -1264,6 +1321,51 @@ def test_fulfil_queue_failure_returns_error(user_db): assert row["release_data"]["source_id"] == "release-123" +def test_fulfil_request_rolls_back_when_queue_raises(user_db): + alice = user_db.create_user(username="alice") + admin = user_db.create_user(username="admin", role="admin") + created = create_request( + user_db, + user_id=alice["id"], + source_hint="prowlarr", + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data=_book_data(), + release_data=_release_data(), + ) + + captured: dict[str, object] = {} + + def exploding_queue(release_data, priority, user_id=None, username=None): + captured["release_data"] = release_data + captured["priority"] = priority + captured["user_id"] = user_id + captured["username"] = username + raise RuntimeError("daemon exploded") + + with pytest.raises(RuntimeError, match="daemon exploded"): + fulfil_request( + user_db, + request_id=created["id"], + admin_user_id=admin["id"], + queue_release=exploding_queue, + ) + + assert captured["priority"] == 0 + assert captured["user_id"] == alice["id"] + assert captured["username"] == "alice" + assert captured["release_data"]["_request_id"] == created["id"] + + row = user_db.get_request(created["id"]) + assert row["status"] == "pending" + assert row["delivery_state"] == "none" + assert row["reviewed_by"] is None + assert row["reviewed_at"] is None + assert row["last_failure_reason"] == "Queue dispatch raised an exception" + assert row["release_data"]["source_id"] == "release-123" + + def test_fulfil_admin_can_override_release_data(user_db): alice = user_db.create_user(username="alice") admin = user_db.create_user(username="admin", role="admin") diff --git a/tests/core/test_search_plan.py b/tests/core/test_search_plan.py index 08ca9a6..e960ebd 100644 --- a/tests/core/test_search_plan.py +++ b/tests/core/test_search_plan.py @@ -1,5 +1,5 @@ -from shelfmark.metadata_providers import BookMetadata from shelfmark.core.search_plan import build_release_search_plan +from shelfmark.metadata_providers import BookMetadata class TestReleaseSearchPlan: diff --git a/tests/core/test_self_user_notification_preferences_api.py b/tests/core/test_self_user_notification_preferences_api.py index 898c6f2..f0e1a84 100644 --- a/tests/core/test_self_user_notification_preferences_api.py +++ b/tests/core/test_self_user_notification_preferences_api.py @@ -54,6 +54,7 @@ class TestSelfNotificationPreferencesTestAction: (plugins_dir / "notifications.json").write_text(json.dumps(notifications_config)) from shelfmark.core.config import config as app_config + app_config.refresh() def test_users_me_notification_test_uses_payload_routes(self, app, user_db): @@ -81,9 +82,7 @@ class TestSelfNotificationPreferencesTestAction: assert resp.status_code == 200 assert resp.json["success"] is True - mock_send.assert_called_once_with( - ["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"] - ) + mock_send.assert_called_once_with(["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"]) def test_users_me_notification_test_requires_user_context(self, app): client = app.test_client() diff --git a/tests/core/test_self_user_routes.py b/tests/core/test_self_user_routes.py index 036cdd2..abd467d 100644 --- a/tests/core/test_self_user_routes.py +++ b/tests/core/test_self_user_routes.py @@ -44,114 +44,107 @@ def _authed_client_for_user(app: Flask, user: dict) -> Any: return client -def test_users_me_edit_context_respects_visible_sections(app, user_db): - user = user_db.create_user(username="alice") - client = _authed_client_for_user(app, user) +def _visible_sections_config_get( + visible_sections: object, +): + def _get(key: str, default: object = None, user_id: int | None = None) -> object: + del user_id + if key == "VISIBLE_SELF_SETTINGS_SECTIONS": + return visible_sections + return default - def build_preferences(_user_db, _user_id, tab_name): - if tab_name == "downloads": - return { - "tab": "downloads", - "keys": ["DESTINATION"], - "fields": [], - "globalValues": {}, - "userOverrides": {}, - "effective": {}, - } - raise AssertionError(f"Unexpected tab requested: {tab_name}") + return _get + + +def test_users_me_edit_context_respects_visible_sections(app, user_db, monkeypatch): + user = user_db.create_user(username="alice") + user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"}) + client = _authed_client_for_user(app, user) + monkeypatch.delenv("INGEST_DIR", raising=False) with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): with patch( - "shelfmark.core.self_user_routes.load_config_file", - side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery"]), ): - with patch( - "shelfmark.core.self_user_routes._build_user_preferences_payload", - side_effect=build_preferences, - ): - resp = client.get("/api/users/me/edit-context") + resp = client.get("/api/users/me/edit-context") assert resp.status_code == 200 assert resp.json["visibleUserSettingsSections"] == ["delivery"] assert resp.json["deliveryPreferences"]["tab"] == "downloads" + assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["value"] == "/books/alice" + assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["source"] == "user_override" + assert "DESTINATION" in resp.json["userOverridableKeys"] assert resp.json["notificationPreferences"] is None - assert resp.json["userOverridableKeys"] == ["DESTINATION"] def test_users_me_edit_context_includes_search_preferences_when_visible(app, user_db): user = user_db.create_user(username="alice") + user_db.set_user_settings( + user["id"], + { + "SEARCH_MODE": "universal", + "METADATA_PROVIDER": "openlibrary", + }, + ) client = _authed_client_for_user(app, user) - def build_preferences(_user_db, _user_id, tab_name): - payloads = { - "downloads": { - "tab": "downloads", - "keys": ["DESTINATION"], - "fields": [], - "globalValues": {}, - "userOverrides": {}, - "effective": {}, - }, - "search_mode": { - "tab": "search_mode", - "keys": ["SEARCH_MODE", "METADATA_PROVIDER"], - "fields": [], - "globalValues": {}, - "userOverrides": {}, - "effective": {}, - }, - } - if tab_name not in payloads: - raise AssertionError(f"Unexpected tab requested: {tab_name}") - return payloads[tab_name] - with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): with patch( - "shelfmark.core.self_user_routes.load_config_file", - side_effect=lambda tab_name: { - "VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery", "search"] - } if tab_name == "users" else {}, + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery", "search"]), ): - with patch( - "shelfmark.core.self_user_routes._build_user_preferences_payload", - side_effect=build_preferences, - ): - resp = client.get("/api/users/me/edit-context") + resp = client.get("/api/users/me/edit-context") assert resp.status_code == 200 assert resp.json["visibleUserSettingsSections"] == ["delivery", "search"] assert resp.json["deliveryPreferences"]["tab"] == "downloads" assert resp.json["searchPreferences"]["tab"] == "search_mode" + assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["value"] == "universal" + assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["source"] == "user_override" + assert "SEARCH_MODE" in resp.json["userOverridableKeys"] + assert "METADATA_PROVIDER" in resp.json["userOverridableKeys"] assert resp.json["notificationPreferences"] is None - assert resp.json["userOverridableKeys"] == ["DESTINATION", "METADATA_PROVIDER", "SEARCH_MODE"] + assert resp.json["userOverridableKeys"] == sorted(resp.json["userOverridableKeys"]) + + +def test_users_me_edit_context_falls_back_to_default_sections_for_invalid_config(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get("bogus"), + ): + resp = client.get("/api/users/me/edit-context") + + assert resp.status_code == 200 + assert resp.json["visibleUserSettingsSections"] == ["delivery", "search", "notifications"] + assert resp.json["deliveryPreferences"] is not None + assert resp.json["searchPreferences"] is not None + assert resp.json["notificationPreferences"] is not None def test_users_me_update_rejects_hidden_section_settings(app, user_db): user = user_db.create_user(username="alice") client = _authed_client_for_user(app, user) - def ordered_overrides(tab_name: str): - if tab_name == "downloads": - return [("DESTINATION", object())] - raise AssertionError(f"Unexpected tab requested: {tab_name}") - with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): with patch( - "shelfmark.core.self_user_routes.load_config_file", - side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery"]), ): - with patch( - "shelfmark.core.self_user_routes._get_ordered_user_overridable_fields", - side_effect=ordered_overrides, - ): - resp = client.put( - "/api/users/me", - json={ - "settings": { - "USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/alice"}], - } - }, - ) + resp = client.put( + "/api/users/me", + json={ + "settings": { + "USER_NOTIFICATION_ROUTES": [ + {"event": "all", "url": "ntfys://ntfy.sh/alice"} + ], + } + }, + ) assert resp.status_code == 400 assert resp.json["error"] == "Some settings are admin-only" @@ -162,28 +155,47 @@ def test_users_me_update_accepts_visible_section_settings(app, user_db): user = user_db.create_user(username="alice") client = _authed_client_for_user(app, user) - def ordered_overrides(tab_name: str): - if tab_name == "downloads": - return [("DESTINATION", object())] - raise AssertionError(f"Unexpected tab requested: {tab_name}") - with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): with patch( - "shelfmark.core.self_user_routes.load_config_file", - side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery"]), ): - with patch( - "shelfmark.core.self_user_routes._get_ordered_user_overridable_fields", - side_effect=ordered_overrides, - ): - with patch( - "shelfmark.core.self_user_routes.validate_user_settings", - side_effect=lambda payload: (payload, []), - ): - resp = client.put( - "/api/users/me", - json={"settings": {"DESTINATION": "/books/alice"}}, - ) + resp = client.put( + "/api/users/me", + json={"settings": {"DESTINATION": "/books/alice"}}, + ) assert resp.status_code == 200 assert user_db.get_user_settings(user["id"]).get("DESTINATION") == "/books/alice" + assert resp.json["settings"]["DESTINATION"] == "/books/alice" + + +def test_users_me_update_rejects_non_object_settings_payload(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery"]), + ): + resp = client.put("/api/users/me", json={"settings": ["DESTINATION"]}) + + assert resp.status_code == 400 + assert resp.json["error"] == "Settings must be an object" + + +def test_users_me_update_rejects_oidc_email_change(app, user_db): + user = user_db.create_user( + username="oidc-user", + oidc_subject="oidc-sub-123", + auth_source="oidc", + ) + client = _authed_client_for_user(app, user) + + with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): + resp = client.put("/api/users/me", json={"email": "new@example.com"}) + + assert resp.status_code == 400 + assert resp.json["error"] == "Cannot change email for OIDC users" + assert user_db.get_user(user_id=user["id"])["email"] is None diff --git a/tests/direct_download/test_handler.py b/tests/direct_download/test_handler.py index 3bca182..cbf7b40 100644 --- a/tests/direct_download/test_handler.py +++ b/tests/direct_download/test_handler.py @@ -7,7 +7,7 @@ from shelfmark.release_sources.direct_download import DirectDownloadHandler def test_direct_download_handler_builds_staging_filename_from_browse_record(monkeypatch): captured = {} - def fake_download_book(book_info, book_path, progress_callback, cancel_flag, status_callback): # noqa: ANN001 + def fake_download_book(book_info, book_path, progress_callback, cancel_flag, status_callback): captured["title"] = book_info.title captured["year"] = book_info.year captured["path"] = book_path @@ -16,7 +16,11 @@ def test_direct_download_handler_builds_staging_filename_from_browse_record(monk import shelfmark.release_sources.direct_download as dd monkeypatch.setattr(dd, "_download_book", fake_download_book) - monkeypatch.setattr(dd.config, "get", lambda key, default=None: "rename" if key == "FILE_ORGANIZATION" else default) + monkeypatch.setattr( + dd.config, + "get", + lambda key, default=None: "rename" if key == "FILE_ORGANIZATION" else default, + ) task = DownloadTask( task_id="92c7879138d18678b763118250228955", @@ -34,3 +38,111 @@ def test_direct_download_handler_builds_staging_filename_from_browse_record(monk assert captured["title"] == "Project Hail Mary: A Novel" assert captured["year"] == "2021" assert captured["path"].name == "Andy Weir - Project Hail Mary_ A Novel (2021).epub" + + +def test_direct_download_handler_uses_source_id_filename_when_organization_disabled(monkeypatch): + captured = {} + + def fake_download_book(book_info, book_path, progress_callback, cancel_flag, status_callback): + captured["path"] = book_path + return "https://example.com/file.epub" + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "_download_book", fake_download_book) + monkeypatch.setattr( + dd.config, + "get", + lambda key, default=None: "none" if key == "FILE_ORGANIZATION" else default, + ) + + task = DownloadTask( + task_id="aa-md5-hash", + source="direct_download", + title="Ignored Human Title", + author="Ignored Author", + year="2024", + format="epub", + ) + + handler = DirectDownloadHandler() + result = handler.download(task, Event(), lambda _progress: None, lambda _status, _message: None) + + assert result is not None + assert captured["path"].name == "aa-md5-hash.epub" + + +def test_direct_download_handler_skips_download_when_cancelled_before_start(monkeypatch): + status_updates: list[tuple[str, str | None]] = [] + + def unexpected_download(*_args, **_kwargs): + raise AssertionError("_download_book should not run when the task is already cancelled") + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "_download_book", unexpected_download) + + task = DownloadTask( + task_id="cancel-me", + source="direct_download", + title="Cancelled Book", + format="epub", + ) + cancel_flag = Event() + cancel_flag.set() + + handler = DirectDownloadHandler() + result = handler.download( + task, + cancel_flag, + lambda _progress: None, + lambda status, message: status_updates.append((status, message)), + ) + + assert result is None + assert status_updates == [("cancelled", "Cancelled")] + + +def test_direct_download_handler_removes_partial_file_when_cancelled_after_download( + monkeypatch, tmp_path +): + status_updates: list[tuple[str, str | None]] = [] + + def fake_download_book(book_info, book_path, progress_callback, cancel_flag, status_callback): + book_path.write_text("partial") + cancel_flag.set() + return "https://example.com/file.epub" + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "_download_book", fake_download_book) + monkeypatch.setattr(dd, "TMP_DIR", tmp_path) + monkeypatch.setattr( + dd.config, + "get", + lambda key, default=None: "rename" if key == "FILE_ORGANIZATION" else default, + ) + + task = DownloadTask( + task_id="cancel-after-download", + source="direct_download", + title="Partial Book", + author="A. Author", + year="2024", + format="epub", + ) + cancel_flag = Event() + + handler = DirectDownloadHandler() + result = handler.download( + task, + cancel_flag, + lambda _progress: None, + lambda status, message: status_updates.append((status, message)), + ) + + expected_path = tmp_path / "A. Author - Partial Book (2024).epub" + + assert result is None + assert not expected_path.exists() + assert status_updates[-1] == ("cancelled", "Cancelled") diff --git a/tests/direct_download/test_search_queries.py b/tests/direct_download/test_search_queries.py index 4dd702a..72440e6 100644 --- a/tests/direct_download/test_search_queries.py +++ b/tests/direct_download/test_search_queries.py @@ -1,6 +1,12 @@ -from shelfmark.metadata_providers import BookMetadata -from shelfmark.release_sources.direct_download import DirectDownloadSource +from shelfmark.core.models import SearchFilters from shelfmark.core.search_plan import build_release_search_plan +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources import BrowseRecord +from shelfmark.release_sources.direct_download import DirectDownloadSource + + +def _browse_record(record_id: str, title: str) -> BrowseRecord: + return BrowseRecord(id=record_id, title=title, source="direct_download") class TestDirectDownloadSearchQueries: @@ -35,3 +41,128 @@ class TestDirectDownloadSearchQueries: assert "The Final Empire Brandon Sanderson" in captured assert "A végső birodalom Brandon Sanderson" in captured assert "Mistborn: The Final Empire Brandon Sanderson" not in captured + + def test_deduplicates_results_across_localized_queries(self, monkeypatch): + captured: list[tuple[str, list[str] | None]] = [] + records_by_query = { + "The Final Empire Brandon Sanderson": [ + _browse_record("shared", "Shared release"), + _browse_record("en-only", "English only"), + ], + "A végső birodalom Brandon Sanderson": [ + _browse_record("shared", "Shared release"), + _browse_record("hu-only", "Hungarian only"), + ], + } + + def fake_search_books(query: str, filters): + captured.append((query, filters.lang)) + return records_by_query[query] + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "search_books", fake_search_books) + + source = DirectDownloadSource() + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + search_author="Brandon Sanderson", + authors=["Brandon Sanderson"], + titles_by_language={ + "en": "Mistborn: The Final Empire", + "hu": "A végső birodalom", + }, + ) + + plan = build_release_search_plan(book, languages=["en", "hu"]) + results = source.search(book, plan, expand_search=True) + + assert captured == [ + ("The Final Empire Brandon Sanderson", ["en"]), + ("A végső birodalom Brandon Sanderson", ["hu"]), + ] + assert [release.source_id for release in results] == ["shared", "en-only", "hu-only"] + + def test_retries_without_language_filters_when_localized_queries_miss(self, monkeypatch): + captured: list[tuple[str, list[str] | None]] = [] + fallback_results = { + "The Final Empire Brandon Sanderson": [ + _browse_record("fallback-en", "Fallback English") + ], + "A végső birodalom Brandon Sanderson": [ + _browse_record("fallback-hu", "Fallback Hungarian") + ], + } + + def fake_search_books(query: str, filters): + captured.append((query, filters.lang)) + if filters.lang: + return [] + return fallback_results[query] + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "search_books", fake_search_books) + + source = DirectDownloadSource() + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + search_author="Brandon Sanderson", + authors=["Brandon Sanderson"], + titles_by_language={ + "en": "Mistborn: The Final Empire", + "hu": "A végső birodalom", + }, + ) + + plan = build_release_search_plan(book, languages=["en", "hu"]) + results = source.search(book, plan, expand_search=True) + + assert captured == [ + ("The Final Empire Brandon Sanderson", ["en"]), + ("A végső birodalom Brandon Sanderson", ["hu"]), + ("The Final Empire Brandon Sanderson", None), + ("A végső birodalom Brandon Sanderson", None), + ] + assert [release.source_id for release in results] == ["fallback-en", "fallback-hu"] + + def test_manual_query_fallback_preserves_other_filters(self, monkeypatch): + captured: list[tuple[str, list[str] | None, list[str] | None]] = [] + + def fake_search_books(query: str, filters): + captured.append((query, filters.lang, filters.format)) + if filters.lang: + return [] + return [_browse_record("manual-1", "Manual result")] + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "search_books", fake_search_books) + + source = DirectDownloadSource() + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + authors=["Brandon Sanderson"], + ) + + plan = build_release_search_plan( + book, + languages=["en"], + manual_query="mistborn custom query", + source_filters=SearchFilters(format=["epub"], sort="newest"), + ) + results = source.search(book, plan) + + assert [release.source_id for release in results] == ["manual-1"] + assert captured == [ + ("mistborn custom query", ["en"], ["epub"]), + ("mistborn custom query", None, ["epub"]), + ] diff --git a/tests/download/test_http_aa_redirects.py b/tests/download/test_http_aa_redirects.py index d3a2a8a..0de05b5 100644 --- a/tests/download/test_http_aa_redirects.py +++ b/tests/download/test_http_aa_redirects.py @@ -2,7 +2,9 @@ import requests class _FakeResponse: - def __init__(self, status_code: int, *, headers: dict | None = None, text: str = "", url: str = "") -> None: + def __init__( + self, status_code: int, *, headers: dict | None = None, text: str = "", url: str = "" + ) -> None: self.status_code = status_code self.headers = headers or {} self.text = text @@ -54,7 +56,9 @@ def test_html_get_page_aa_cross_host_redirect_rotates_mirror(monkeypatch): def fake_get(url: str, **kwargs): calls.append({"url": url, "allow_redirects": kwargs.get("allow_redirects")}) if url.startswith("https://annas-archive.li/"): - return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url) + return _FakeResponse( + 302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url + ) if url.startswith("https://annas-archive.gl/"): return _FakeResponse(200, text="OK", url=url) raise AssertionError(f"Unexpected URL: {url}") @@ -72,7 +76,9 @@ def test_html_get_page_aa_cross_host_redirect_rotates_mirror(monkeypatch): assert html == "OK" assert calls[0]["allow_redirects"] is False # AA redirects handled manually assert calls[0]["url"].startswith("https://annas-archive.li/") - assert calls[1]["url"].startswith("https://annas-archive.gl/") # rotated away from redirect target + assert calls[1]["url"].startswith( + "https://annas-archive.gl/" + ) # rotated away from redirect target def test_html_get_page_aa_same_host_redirect_is_followed(monkeypatch): @@ -126,7 +132,9 @@ def test_html_get_page_locked_aa_does_not_fail_over_on_cross_host_redirect(monke def fake_get(url: str, **kwargs): calls.append(url) if url.startswith("https://annas-archive.li/"): - return _FakeResponse(302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url) + return _FakeResponse( + 302, headers={"Location": "https://annas-archive.pm/search?q=test"}, url=url + ) raise AssertionError(f"Unexpected URL: {url}") monkeypatch.setattr(http.requests, "get", fake_get) diff --git a/tests/download/test_http_bypasser_fallbacks.py b/tests/download/test_http_bypasser_fallbacks.py index c40d02a..b13177d 100644 --- a/tests/download/test_http_bypasser_fallbacks.py +++ b/tests/download/test_http_bypasser_fallbacks.py @@ -89,3 +89,23 @@ def test_download_url_ignores_zlib_cookie_refresh_failure(monkeypatch): ) assert result is None + + +def test_get_bypassed_page_uses_external_bypasser_when_enabled(monkeypatch): + import shelfmark.download.http as http + + calls: list[tuple] = [] + + class FakeExternalBypasser: + def get_bypassed_page(self, url, selector, cancel_flag): + calls.append((url, selector, cancel_flag)) + return "EXT" + + monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: True) + monkeypatch.setattr(http, "_get_external_bypasser", lambda: FakeExternalBypasser()) + + selector = object() + cancel_flag = object() + + assert http.get_bypassed_page("https://example.com", selector, cancel_flag) == "EXT" + assert calls == [("https://example.com", selector, cancel_flag)] diff --git a/tests/download/test_http_download_url.py b/tests/download/test_http_download_url.py new file mode 100644 index 0000000..539e22e --- /dev/null +++ b/tests/download/test_http_download_url.py @@ -0,0 +1,158 @@ +"""Focused tests for download_url() retry, fallback, and resume behavior.""" + +import requests + + +class _FakeResponse: + def __init__( + self, + status_code: int, + *, + headers: dict | None = None, + chunks: list[bytes] | None = None, + url: str = "", + iter_error: requests.exceptions.RequestException | None = None, + ) -> None: + self.status_code = status_code + self.headers = headers or {} + self.url = url + self._chunks = chunks or [] + self._iter_error = iter_error + + def raise_for_status(self) -> None: + if self.status_code >= 400: + error = requests.exceptions.HTTPError(f"HTTP {self.status_code}") + error.response = self + raise error + + def iter_content(self, chunk_size: int = 8192): + del chunk_size + for chunk in self._chunks: + yield chunk + if self._iter_error: + raise self._iter_error + + +class _DummyProgressBar: + def __init__(self, *args, **kwargs) -> None: + del args, kwargs + + def update(self, amount: int) -> None: + del amount + + def close(self) -> None: + return None + + +def _prepare_download_test(monkeypatch): + import shelfmark.download.http as http + + monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False) + monkeypatch.setattr(http, "get_proxies", lambda _url: {}) + monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True) + monkeypatch.setattr(http.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(http, "tqdm", _DummyProgressBar) + + return http + + +def test_download_url_returns_none_on_rate_limit(monkeypatch): + http = _prepare_download_test(monkeypatch) + status_updates: list[tuple[str, str | None]] = [] + + def fake_get(_url: str, **_kwargs): + error = requests.exceptions.HTTPError("busy") + error.response = _FakeResponse(429, url=_url) + raise error + + monkeypatch.setattr(http.requests, "get", fake_get) + + result = http.download_url( + "https://example.com/file.epub", + status_callback=lambda status, message: status_updates.append((status, message)), + ) + + assert result is None + assert status_updates == [("resolving", "Server busy, trying next")] + + +def test_download_url_returns_none_on_timeout(monkeypatch): + http = _prepare_download_test(monkeypatch) + status_updates: list[tuple[str, str | None]] = [] + + def fake_get(_url: str, **_kwargs): + raise requests.exceptions.Timeout("read timed out") + + monkeypatch.setattr(http.requests, "get", fake_get) + + result = http.download_url( + "https://example.com/file.epub", + status_callback=lambda status, message: status_updates.append((status, message)), + ) + + assert result is None + assert status_updates == [("resolving", "Server timed out, trying next")] + + +def test_download_url_rejects_html_error_pages(monkeypatch): + http = _prepare_download_test(monkeypatch) + status_updates: list[tuple[str, str | None]] = [] + + def fake_get(url: str, **_kwargs): + return _FakeResponse( + 200, + headers={ + "content-length": "100", + "content-type": "text/html; charset=utf-8", + }, + chunks=[b"busy"], + url=url, + ) + + monkeypatch.setattr(http.requests, "get", fake_get) + + result = http.download_url( + "https://example.com/file.epub", + status_callback=lambda status, message: status_updates.append((status, message)), + ) + + assert result is None + assert status_updates == [("downloading", "")] + + +def test_download_url_resumes_partial_download_after_connection_error(monkeypatch): + http = _prepare_download_test(monkeypatch) + + calls: list[dict[str, object]] = [] + responses = [ + _FakeResponse( + 200, + headers={ + "content-length": "8", + "content-type": "application/octet-stream", + }, + chunks=[b"abcd"], + url="https://example.com/file.epub", + iter_error=requests.exceptions.ConnectionError("socket reset"), + ), + _FakeResponse( + 206, + headers={"content-length": "4"}, + chunks=[b"efgh"], + url="https://example.com/file.epub", + ), + ] + + def fake_get(url: str, **kwargs): + calls.append({"url": url, "headers": dict(kwargs.get("headers", {}))}) + return responses[len(calls) - 1] + + monkeypatch.setattr(http.requests, "get", fake_get) + + result = http.download_url("https://example.com/file.epub") + + assert result is not None + assert result.getvalue() == b"abcdefgh" + assert len(calls) == 2 + assert "Range" not in calls[0]["headers"] + assert calls[1]["headers"]["Range"] == "bytes=4-" diff --git a/tests/download/test_network_dns_failover.py b/tests/download/test_network_dns_failover.py new file mode 100644 index 0000000..aa63d8a --- /dev/null +++ b/tests/download/test_network_dns_failover.py @@ -0,0 +1,122 @@ +"""Tests for DNS failover and rotation behavior.""" + + +def _set_auto_dns_mode(monkeypatch): + import shelfmark.download.network as network + + def fake_get(key, default=""): + if key == "CUSTOM_DNS": + return "auto" + if key == "USING_TOR": + return False + return default + + monkeypatch.setattr(network.app_config, "get", fake_get) + return network + + +def test_switch_dns_provider_updates_runtime_state_and_notifies_listeners(monkeypatch): + network = _set_auto_dns_mode(monkeypatch) + events: list[tuple] = [] + + monkeypatch.setattr( + network, + "DNS_PROVIDERS", + [ + ("cloudflare", ["1.1.1.1", "1.0.0.1"], "https://cloudflare-dns.com/dns-query"), + ("google", ["8.8.8.8", "8.8.4.4"], "https://dns.google/resolve"), + ], + ) + monkeypatch.setattr(network, "_current_dns_index", -1) + monkeypatch.setattr(network, "_dns_exhausted_logged", False) + monkeypatch.setattr(network, "_save_state", lambda **kwargs: events.append(("save", kwargs))) + monkeypatch.setattr(network, "init_dns_resolvers", lambda: events.append(("init",))) + monkeypatch.setattr( + network, + "_notify_dns_rotation", + lambda provider, servers, doh: events.append(("notify", provider, servers, doh)), + ) + + assert network.switch_dns_provider() is True + assert network._current_dns_index == 0 + assert network.CUSTOM_DNS == ["1.1.1.1", "1.0.0.1"] + assert network.DOH_SERVER == "https://cloudflare-dns.com/dns-query" + assert events == [ + ("save", {"dns_provider": "cloudflare"}), + ("init",), + ("notify", "cloudflare", ["1.1.1.1", "1.0.0.1"], "https://cloudflare-dns.com/dns-query"), + ] + + +def test_rotate_dns_provider_cycles_back_to_first_provider(monkeypatch): + network = _set_auto_dns_mode(monkeypatch) + events: list[tuple] = [] + + monkeypatch.setattr( + network, + "DNS_PROVIDERS", + [ + ("cloudflare", ["1.1.1.1", "1.0.0.1"], "https://cloudflare-dns.com/dns-query"), + ("google", ["8.8.8.8", "8.8.4.4"], "https://dns.google/resolve"), + ], + ) + monkeypatch.setattr(network, "_current_dns_index", 1) + monkeypatch.setattr(network, "_dns_exhausted_logged", False) + monkeypatch.setattr(network, "_save_state", lambda **kwargs: events.append(("save", kwargs))) + monkeypatch.setattr(network, "init_dns_resolvers", lambda: events.append(("init",))) + monkeypatch.setattr( + network, + "_notify_dns_rotation", + lambda provider, servers, doh: events.append(("notify", provider, servers, doh)), + ) + + assert network.rotate_dns_provider() is True + assert network._current_dns_index == 0 + assert network.CUSTOM_DNS == ["1.1.1.1", "1.0.0.1"] + assert network.DOH_SERVER == "https://cloudflare-dns.com/dns-query" + assert events == [ + ("save", {"dns_provider": "cloudflare"}), + ("init",), + ("notify", "cloudflare", ["1.1.1.1", "1.0.0.1"], "https://cloudflare-dns.com/dns-query"), + ] + + +def test_system_failover_getaddrinfo_retries_after_dns_switch(monkeypatch): + network = _set_auto_dns_mode(monkeypatch) + calls: list[tuple] = [] + + monkeypatch.setattr( + network, + "DNS_PROVIDERS", + [ + ("cloudflare", ["1.1.1.1", "1.0.0.1"], "https://cloudflare-dns.com/dns-query"), + ("google", ["8.8.8.8", "8.8.4.4"], "https://dns.google/resolve"), + ], + ) + monkeypatch.setattr(network, "_current_dns_index", 0) + + def fake_original_getaddrinfo(*args): + calls.append(("original", args)) + raise OSError("system DNS failed") + + def fake_retry_getaddrinfo(*args): + calls.append(("retry", args)) + return [(network.socket.AF_INET, network.socket.SOCK_STREAM, 6, "", ("203.0.113.10", 443))] + + monkeypatch.setattr(network, "original_getaddrinfo", fake_original_getaddrinfo) + monkeypatch.setattr(network.socket, "getaddrinfo", fake_retry_getaddrinfo) + monkeypatch.setattr(network, "_is_local_address", lambda _host: False) + monkeypatch.setattr(network, "_is_ip_address", lambda _host: False) + monkeypatch.setattr(network, "switch_dns_provider", lambda: calls.append(("switch",)) or True) + + resolver = network.create_system_failover_getaddrinfo() + result = resolver("example.com", "443") + + assert calls == [ + ("original", ("example.com", "443", 0, 0, 0, 0)), + ("switch",), + ("retry", ("example.com", "443", 0, 0, 0, 0)), + ] + assert result == [ + (network.socket.AF_INET, network.socket.SOCK_STREAM, 6, "", ("203.0.113.10", 443)) + ] diff --git a/tests/download/test_network_proxy_selection.py b/tests/download/test_network_proxy_selection.py new file mode 100644 index 0000000..c0bb7ba --- /dev/null +++ b/tests/download/test_network_proxy_selection.py @@ -0,0 +1,61 @@ +"""Tests for proxy selection and NO_PROXY bypass handling.""" + + +def _set_proxy_config(monkeypatch, **values): + import shelfmark.download.network as network + + def fake_get(key, default=""): + return values.get(key, default) + + monkeypatch.setattr(network.app_config, "get", fake_get) + return network + + +def test_get_proxies_bypasses_exact_and_wildcard_no_proxy_hosts(monkeypatch): + network = _set_proxy_config( + monkeypatch, + PROXY_MODE="http", + HTTP_PROXY="http://proxy.local:8080", + HTTPS_PROXY="https://secure-proxy.local:8443", + NO_PROXY="LOCALHOST, *.Internal, 10.*", + ) + + assert network.should_bypass_proxy("https://localhost:8080") is True + assert network.should_bypass_proxy("https://API.Internal/path") is True + assert network.should_bypass_proxy("https://10.1.2.3/file") is True + assert network.should_bypass_proxy("https://example.com") is False + + assert network.get_proxies("https://localhost:8080") == {} + assert network.get_proxies("https://example.com") == { + "http": "http://proxy.local:8080", + "https": "https://secure-proxy.local:8443", + } + + +def test_get_proxies_falls_back_to_http_proxy_for_https(monkeypatch): + network = _set_proxy_config( + monkeypatch, + PROXY_MODE="http", + HTTP_PROXY="http://proxy.local:8080", + HTTPS_PROXY="", + NO_PROXY="", + ) + + assert network.get_proxies("https://example.com") == { + "http": "http://proxy.local:8080", + "https": "http://proxy.local:8080", + } + + +def test_get_proxies_returns_socks_proxy_for_both_schemes(monkeypatch): + network = _set_proxy_config( + monkeypatch, + PROXY_MODE="socks5", + SOCKS5_PROXY="socks5://proxy.local:1080", + NO_PROXY="", + ) + + assert network.get_proxies("https://example.com") == { + "http": "socks5://proxy.local:1080", + "https": "socks5://proxy.local:1080", + } diff --git a/tests/download/test_orchestrator_lifecycle.py b/tests/download/test_orchestrator_lifecycle.py index 6d4513c..5695cad 100644 --- a/tests/download/test_orchestrator_lifecycle.py +++ b/tests/download/test_orchestrator_lifecycle.py @@ -68,7 +68,9 @@ def test_concurrent_download_loop_logs_and_recovers_after_loop_error(monkeypatch ] -def test_concurrent_download_loop_recovers_and_processes_task_after_transient_loop_error(monkeypatch): +def test_concurrent_download_loop_recovers_and_processes_task_after_transient_loop_error( + monkeypatch, +): import threading import shelfmark.download.orchestrator as orchestrator @@ -92,8 +94,12 @@ def test_concurrent_download_loop_recovers_and_processes_task_after_transient_lo def cancel_download(self, task_id: str) -> None: # pragma: no cover - unused raise AssertionError(f"cancel_download unexpectedly called for {task_id}") - def update_status_message(self, task_id: str, message: str) -> None: # pragma: no cover - unused - raise AssertionError(f"update_status_message unexpectedly called for {task_id}: {message}") + def update_status_message( + self, task_id: str, message: str + ) -> None: # pragma: no cover - unused + raise AssertionError( + f"update_status_message unexpectedly called for {task_id}: {message}" + ) queue = FlakyQueue() error_trace = MagicMock() diff --git a/tests/download/test_postprocess_scan_blocking_io.py b/tests/download/test_postprocess_scan_blocking_io.py index efb306d..16fd25b 100644 --- a/tests/download/test_postprocess_scan_blocking_io.py +++ b/tests/download/test_postprocess_scan_blocking_io.py @@ -66,4 +66,3 @@ def test_extract_archive_files_runs_extract_via_run_blocking_io(tmp_path, monkey ) assert extract_called, "Expected extract_archive_files to call extract_archive" - diff --git a/tests/download/test_ssl_verify.py b/tests/download/test_ssl_verify.py index a629c06..c1f8d0a 100644 --- a/tests/download/test_ssl_verify.py +++ b/tests/download/test_ssl_verify.py @@ -8,37 +8,58 @@ import pytest # get_ssl_verify() # --------------------------------------------------------------------------- + class TestGetSslVerify: """Tests for get_ssl_verify() return values across all modes.""" def test_enabled_returns_true(self, monkeypatch): import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d, + ) assert network.get_ssl_verify("https://example.com") is True def test_enabled_returns_true_for_local_url(self, monkeypatch): import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d, + ) assert network.get_ssl_verify("https://localhost:8080") is True def test_disabled_returns_false_for_public_url(self, monkeypatch): import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d, + ) assert network.get_ssl_verify("https://example.com") is False def test_disabled_returns_false_for_local_url(self, monkeypatch): import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d, + ) assert network.get_ssl_verify("https://192.168.1.1:9091") is False def test_disabled_returns_false_with_no_url(self, monkeypatch): import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d, + ) assert network.get_ssl_verify() is False def test_default_when_unset_returns_true(self, monkeypatch): @@ -57,7 +78,11 @@ class TestGetSslVerifyDisabledLocal: import shelfmark.download.network as network self.network = network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d, + ) # --- Should return False (local addresses) --- @@ -131,6 +156,7 @@ class TestGetSslVerifyDisabledLocal: # _apply_ssl_warning_suppression() # --------------------------------------------------------------------------- + class TestApplySslWarningSuppression: """Tests for urllib3 InsecureRequestWarning suppression toggling.""" @@ -138,6 +164,7 @@ class TestApplySslWarningSuppression: def _reset_suppression_flag(self): """Ensure the module-level flag is clean before each test.""" import shelfmark.download.network as network + original = network._ssl_warnings_suppressed yield network._ssl_warnings_suppressed = original @@ -147,7 +174,11 @@ class TestApplySslWarningSuppression: import shelfmark.download.network as network network._ssl_warnings_suppressed = False - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d, + ) filters_before = list(warnings.filters) network._apply_ssl_warning_suppression() @@ -160,10 +191,14 @@ class TestApplySslWarningSuppression: import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d, + ) network._apply_ssl_warning_suppression() - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(record=True): warnings.simplefilter("always") warnings.warn("test", urllib3.exceptions.InsecureRequestWarning) @@ -171,7 +206,11 @@ class TestApplySslWarningSuppression: # should be empty after suppression is applied. However, our catch_warnings # with "always" takes precedence within the context manager. Instead, check # that the filter was installed. - filters = [f for f in warnings.filters if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning] + filters = [ + f + for f in warnings.filters + if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning + ] assert len(filters) > 0 def test_disabled_local_mode_suppresses_warnings(self, monkeypatch): @@ -179,10 +218,18 @@ class TestApplySslWarningSuppression: import shelfmark.download.network as network - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled_local" if k == "CERTIFICATE_VALIDATION" else d, + ) network._apply_ssl_warning_suppression() - filters = [f for f in warnings.filters if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning] + filters = [ + f + for f in warnings.filters + if len(f) >= 3 and f[2] is urllib3.exceptions.InsecureRequestWarning + ] assert len(filters) > 0 def test_enabled_mode_restores_warnings(self, monkeypatch): @@ -191,17 +238,28 @@ class TestApplySslWarningSuppression: import shelfmark.download.network as network # First suppress - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "disabled" if k == "CERTIFICATE_VALIDATION" else d, + ) network._apply_ssl_warning_suppression() # Then restore - monkeypatch.setattr(network.app_config, "get", lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d) + monkeypatch.setattr( + network.app_config, + "get", + lambda k, d="": "enabled" if k == "CERTIFICATE_VALIDATION" else d, + ) network._apply_ssl_warning_suppression() # "default" filter should be present for InsecureRequestWarning default_filters = [ - f for f in warnings.filters - if len(f) >= 3 and f[0] == "default" and f[2] is urllib3.exceptions.InsecureRequestWarning + f + for f in warnings.filters + if len(f) >= 3 + and f[0] == "default" + and f[2] is urllib3.exceptions.InsecureRequestWarning ] assert len(default_filters) > 0 @@ -210,6 +268,7 @@ class TestApplySslWarningSuppression: # Settings registration # --------------------------------------------------------------------------- + class TestCertificateValidationSetting: """Tests for the CERTIFICATE_VALIDATION settings field registration.""" @@ -250,13 +309,16 @@ class TestCertificateValidationSetting: # Live-apply on settings save # --------------------------------------------------------------------------- + def test_update_settings_certificate_validation_triggers_suppression(monkeypatch): """Changing CERTIFICATE_VALIDATION via update_settings calls _apply_ssl_warning_suppression.""" import shelfmark.config.settings # noqa: F401 — ensure settings tabs are registered from shelfmark.core.config import config as config_obj from shelfmark.core.settings_registry import update_settings - monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True) + monkeypatch.setattr( + "shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True + ) monkeypatch.setattr(config_obj, "refresh", lambda: None) called = {"count": 0} @@ -281,7 +343,9 @@ def test_update_settings_certificate_validation_logs_live_apply_failure(monkeypa from shelfmark.core.config import config as config_obj from shelfmark.core.settings_registry import update_settings - monkeypatch.setattr("shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True) + monkeypatch.setattr( + "shelfmark.core.settings_registry.save_config_file", lambda _tab, _values: True + ) monkeypatch.setattr(config_obj, "refresh", lambda: None) import shelfmark.download.network as network diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index c980c59..c48e34d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -12,7 +12,7 @@ import time from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest import requests @@ -28,6 +28,8 @@ POLL_INTERVAL = 2 DOWNLOAD_TIMEOUT = 300 # 5 minutes max for downloads E2E_USERNAME_ENV = "E2E_USERNAME" E2E_PASSWORD_ENV = "E2E_PASSWORD" +TERMINAL_DOWNLOAD_STATES = {"complete", "done", "available", "error", "cancelled"} +SUCCESS_DOWNLOAD_STATES = {"complete", "done", "available"} @dataclass @@ -58,6 +60,10 @@ class APIClient: kwargs.setdefault("timeout", self.timeout) return self.session.delete(f"{self.base_url}{path}", **kwargs) + def close(self) -> None: + """Close the underlying HTTP session.""" + self.session.close() + def wait_for_health(self, max_wait: int = 30) -> bool: """Wait for the server to be healthy.""" start = time.time() @@ -66,28 +72,75 @@ class APIClient: resp = self.get("/api/health") if resp.status_code == 200: return True - except requests.exceptions.ConnectionError: + except requests.exceptions.RequestException: pass time.sleep(1) return False -def _get_auth_state(client: APIClient) -> dict[str, object]: +def assert_json_object(response: requests.Response, *, context: str) -> dict[str, Any]: + """Assert that a response is a JSON object.""" + assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" + try: + data = response.json() + except ValueError: + pytest.fail(f"{context} did not return valid JSON: {response.text}") + assert isinstance(data, dict), f"{context} did not return a JSON object: {data!r}" + return data + + +def assert_json_list(response: requests.Response, *, context: str) -> list[Any]: + """Assert that a response is a JSON list.""" + assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" + try: + data = response.json() + except ValueError: + pytest.fail(f"{context} did not return valid JSON: {response.text}") + assert isinstance(data, list), f"{context} did not return a JSON list: {data!r}" + return data + + +def assert_queued_download_response( + response: requests.Response, + *, + expected_priority: int = 0, +) -> dict[str, Any]: + """Assert the shared queued-download payload shape.""" + data = assert_json_object(response, context="download queue") + assert data == {"status": "queued", "priority": expected_priority} + return data + + +def assert_queue_order_response(response: requests.Response) -> list[dict[str, Any]]: + """Assert the queue order response shape.""" + data = assert_json_object(response, context="queue order") + queue = data.get("queue") + assert isinstance(queue, list), f"queue order payload missing queue list: {data!r}" + for entry in queue: + assert isinstance(entry, dict), f"queue entry is not a JSON object: {entry!r}" + assert isinstance(entry.get("id"), str) + assert isinstance(entry.get("priority"), int) + assert isinstance(entry.get("added_time"), (int, float)) + assert isinstance(entry.get("status"), str) + return queue + + +def _get_auth_state(client: APIClient) -> dict[str, object] | None: """Read the live server auth state for auth-sensitive E2E tests.""" try: response = client.get("/api/auth/check") except requests.exceptions.RequestException: - return {} + return None if response.status_code != 200: - return {} + return None try: payload = response.json() except ValueError: - return {} + return None - return payload if isinstance(payload, dict) else {} + return payload if isinstance(payload, dict) else None def _login_with_env_credentials(client: APIClient) -> bool: @@ -122,19 +175,28 @@ def _is_explicit_e2e_run(markexpr: str, args: list[str]) -> bool: base = arg.split("::", maxsplit=1)[0] parts = Path(base).parts if "tests" not in parts: - return False + continue tests_index = parts.index("tests") - if len(parts) <= tests_index + 1 or parts[tests_index + 1] != "e2e": - return False + if len(parts) > tests_index + 1 and parts[tests_index + 1] == "e2e": + return True - return True + return False def _require_authenticated_client(client: APIClient, *, strict: bool) -> APIClient: """Require an authenticated client for protected-route E2E tests.""" auth_state = _get_auth_state(client) - if not auth_state or not auth_state.get("auth_required"): + if auth_state is None: + message = ( + "Unable to read auth state from the live server. " + "Check the stack or run against a reachable instance." + ) + if strict: + pytest.fail(message) + pytest.skip(message) + + if not auth_state.get("auth_required"): return client if auth_state.get("authenticated"): @@ -173,6 +235,21 @@ def _require_authenticated_client(client: APIClient, *, strict: bool) -> APIClie pytest.skip(message) +def _require_healthy_server(base_url: str, *, strict: bool) -> str: + """Require a reachable live server before running live E2E tests.""" + client = APIClient(base_url=base_url) + try: + if client.wait_for_health(): + return base_url + finally: + client.close() + + message = "Server not available - ensure the app is running" + if strict: + pytest.fail(message) + pytest.skip(message) + + @dataclass class DownloadTracker: """Tracks downloads for cleanup after tests.""" @@ -188,7 +265,7 @@ class DownloadTracker: def cleanup(self) -> None: """Cancel all tracked downloads.""" for book_id in self.queued_ids: - with suppress(Exception): + with suppress(requests.exceptions.RequestException): self.client.delete(f"/api/download/{book_id}/cancel") self.queued_ids.clear() @@ -218,23 +295,28 @@ class DownloadTracker: continue status_data = resp.json() + if not isinstance(status_data, dict): + time.sleep(POLL_INTERVAL) + continue # Check each status category for state in target_states: - if state in status_data and book_id in status_data[state]: + state_entries = status_data.get(state) + if isinstance(state_entries, dict) and book_id in state_entries: return { "state": state, - "data": status_data[state][book_id], + "data": state_entries[book_id], } # Check for error state - if "error" in status_data and book_id in status_data["error"]: + error_entries = status_data.get("error") + if isinstance(error_entries, dict) and book_id in error_entries: return { "state": "error", - "data": status_data["error"][book_id], + "data": error_entries[book_id], } - except Exception: + except requests.exceptions.RequestException, ValueError, TypeError: pass time.sleep(POLL_INTERVAL) @@ -249,15 +331,13 @@ def base_url() -> str: @pytest.fixture(scope="session") -def healthy_base_url(base_url: str) -> str: +def healthy_base_url(base_url: str, request: pytest.FixtureRequest) -> str: """Ensure the live server is reachable before creating per-test clients.""" - client = APIClient(base_url=base_url) - try: - if not client.wait_for_health(): - pytest.skip("Server not available - ensure the app is running") - return base_url - finally: - client.session.close() + strict = _is_explicit_e2e_run( + getattr(request.config.option, "markexpr", ""), + list(getattr(request.config, "args", [])), + ) + return _require_healthy_server(base_url, strict=strict) @pytest.fixture @@ -265,7 +345,7 @@ def api_client(healthy_base_url: str) -> Iterator[APIClient]: """Create a fresh API client for each E2E test.""" client = APIClient(base_url=healthy_base_url) yield client - client.session.close() + client.close() @pytest.fixture @@ -300,4 +380,4 @@ def server_config(healthy_base_url: str) -> dict: return {} return resp.json() finally: - client.session.close() + client.close() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 15ee703..9caa749 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -8,7 +8,19 @@ Run with: uv run pytest tests/e2e/ -v -m e2e import pytest -from .conftest import APIClient, DownloadTracker +from .conftest import ( + APIClient, + DownloadTracker, + assert_queue_order_response, + assert_queued_download_response, +) + + +def _assert_json_object(response, *, status_code: int = 200) -> dict: + assert response.status_code == status_code + data = response.json() + assert isinstance(data, dict) + return data @pytest.mark.e2e @@ -37,26 +49,23 @@ class TestConfigEndpoint: """Tests for the configuration endpoint.""" def test_config_returns_expected_fields(self, protected_api_client: APIClient): - """Test that config includes expected configuration fields.""" - resp = protected_api_client.get("/api/config") - - assert resp.status_code == 200 - data = resp.json() - # Config should be a dict with various settings - assert isinstance(data, dict) - # Should have some standard config fields - assert "supported_formats" in data or "book_languages" in data + """Test that config exposes the stable frontend contract.""" + data = _assert_json_object(protected_api_client.get("/api/config")) + assert isinstance(data["supported_formats"], list) + assert isinstance(data["supported_audiobook_formats"], list) + assert isinstance(data["book_languages"], list) + assert isinstance(data["settings_enabled"], bool) + assert isinstance(data["onboarding_complete"], bool) + assert isinstance(data["search_mode"], str) + assert isinstance(data["default_release_source"], str) def test_config_returns_supported_formats(self, protected_api_client: APIClient): """Test that config includes supported formats.""" - resp = protected_api_client.get("/api/config") - - data = resp.json() - assert "supported_formats" in data - assert isinstance(data["supported_formats"], list) - # Should include common ebook formats + data = _assert_json_object(protected_api_client.get("/api/config")) formats = data["supported_formats"] - assert "epub" in formats or "EPUB" in [f.upper() for f in formats] + assert formats + assert all(isinstance(fmt, str) for fmt in formats) + assert "epub" in {fmt.lower() for fmt in formats} @pytest.mark.e2e @@ -73,12 +82,22 @@ class TestReleaseSourcesEndpoint: def test_release_sources_have_required_fields(self, protected_api_client: APIClient): """Test that each release source has required fields.""" - resp = protected_api_client.get("/api/release-sources") - - data = resp.json() + data = protected_api_client.get("/api/release-sources").json() for source in data: - assert "name" in source - assert "display_name" in source or "label" in source + assert set(source) == { + "name", + "display_name", + "enabled", + "supported_content_types", + "browse_results_are_releases", + "can_be_default", + } + assert isinstance(source["name"], str) + assert isinstance(source["display_name"], str) + assert isinstance(source["enabled"], bool) + assert isinstance(source["supported_content_types"], list) + assert isinstance(source["browse_results_are_releases"], bool) + assert isinstance(source["can_be_default"], bool) @pytest.mark.e2e @@ -86,29 +105,32 @@ class TestMetadataProvidersEndpoint: """Tests for the metadata providers endpoint.""" def test_providers_returns_data(self, protected_api_client: APIClient): - """Test that providers endpoint returns provider data.""" - resp = protected_api_client.get("/api/metadata/providers") - - assert resp.status_code == 200 - data = resp.json() - # May be list or dict depending on implementation - assert isinstance(data, (list, dict)) + """Test that providers endpoint returns the documented object contract.""" + data = _assert_json_object(protected_api_client.get("/api/metadata/providers")) + assert set(data) == { + "providers", + "configured_provider", + "configured_provider_audiobook", + "configured_provider_combined", + } + assert isinstance(data["providers"], list) def test_providers_have_required_fields(self, protected_api_client: APIClient): """Test that each provider has required fields.""" - resp = protected_api_client.get("/api/metadata/providers") - - data = resp.json() - # Handle both list and dict formats - if isinstance(data, dict): - providers = list(data.values()) if data else [] - else: - providers = data - - for provider in providers: - if isinstance(provider, dict): - # Should have name or be identifiable - assert "name" in provider or "id" in provider or "label" in provider + data = _assert_json_object(protected_api_client.get("/api/metadata/providers")) + for provider in data["providers"]: + assert set(provider) == { + "name", + "display_name", + "requires_auth", + "enabled", + "available", + } + assert isinstance(provider["name"], str) + assert isinstance(provider["display_name"], str) + assert isinstance(provider["requires_auth"], bool) + assert isinstance(provider["enabled"], bool) + assert isinstance(provider["available"], bool) @pytest.mark.e2e @@ -119,57 +141,56 @@ class TestMetadataSearch: """Test that search requires a query parameter.""" resp = protected_api_client.get("/api/metadata/search") - # Should return error for missing query - assert resp.status_code in [400, 422] + assert resp.status_code == 400 + assert resp.json() == {"error": "Either 'query' or search field values are required"} def test_search_returns_results(self, protected_api_client: APIClient): """Test that search returns results for a known book.""" resp = protected_api_client.get("/api/metadata/search", params={"query": "1984 Orwell"}) - # May return 200 with results or 503 if provider unavailable if resp.status_code == 200: + data = _assert_json_object(resp) + assert isinstance(data["books"], list) + assert isinstance(data["provider"], str) + assert data["query"] == "1984 Orwell" + assert isinstance(data["page"], int) + assert isinstance(data["total_found"], int) + assert isinstance(data["has_more"], bool) + else: + assert resp.status_code == 503 data = resp.json() - # Response may be list directly, or dict with results key - assert "results" in data or isinstance(data, list) or "query" in data + assert isinstance(data, dict) + assert "error" in data + assert "message" in data def test_search_with_provider_filter(self, protected_api_client: APIClient): """Test searching with a specific provider.""" - # Get available providers first providers_resp = protected_api_client.get("/api/metadata/providers") if providers_resp.status_code != 200: pytest.skip("Could not get providers") providers_data = providers_resp.json() - if not providers_data: + providers = providers_data.get("providers", []) + if not providers: pytest.skip("No providers available") - # Handle both list and dict formats - if isinstance(providers_data, dict): - # Dict format: get first provider name from keys or values - if providers_data: - first_key = list(providers_data.keys())[0] - provider_info = providers_data[first_key] - provider_name = ( - provider_info.get("name", first_key) - if isinstance(provider_info, dict) - else first_key - ) - else: - pytest.skip("No providers available") - else: - # List format - provider_name = providers_data[0].get("name") if providers_data else None - - if not provider_name: - pytest.skip("Could not determine provider name") + provider_name = providers[0]["name"] resp = protected_api_client.get( "/api/metadata/search", params={"query": "Moby Dick", "provider": provider_name}, ) - # Should return 200 or 503 (provider unavailable) - assert resp.status_code in [200, 503] + if resp.status_code == 200: + data = _assert_json_object(resp) + assert data["provider"] == provider_name + assert data["query"] == "Moby Dick" + assert isinstance(data["books"], list) + else: + assert resp.status_code == 503 + data = resp.json() + assert isinstance(data, dict) + assert "error" in data @pytest.mark.e2e @@ -182,8 +203,10 @@ class TestStatusEndpoint: assert resp.status_code == 200 data = resp.json() - # Should have standard status categories assert isinstance(data, dict) + for status_name, tasks in data.items(): + assert isinstance(status_name, str) + assert isinstance(tasks, dict) def test_active_downloads_endpoint(self, protected_api_client: APIClient): """Test the active downloads endpoint.""" @@ -191,7 +214,8 @@ class TestStatusEndpoint: assert resp.status_code == 200 data = resp.json() - assert isinstance(data, (list, dict)) + assert data == {"active_downloads": data["active_downloads"]} + assert isinstance(data["active_downloads"], list) @pytest.mark.e2e @@ -202,14 +226,8 @@ class TestQueueEndpoint: """Test that queue order endpoint returns queue data.""" resp = protected_api_client.get("/api/queue/order") - assert resp.status_code == 200 - data = resp.json() - # May return list directly or dict with queue key - if isinstance(data, dict): - assert "queue" in data - assert isinstance(data["queue"], list) - else: - assert isinstance(data, list) + queue = assert_queue_order_response(resp) + assert isinstance(queue, list) @pytest.mark.e2e @@ -226,7 +244,16 @@ class TestSettingsEndpoint: assert resp.status_code == 200 data = resp.json() - assert isinstance(data, (list, dict)) + assert data == {"tabs": data["tabs"], "groups": data["groups"]} + assert isinstance(data["tabs"], list) + assert isinstance(data["groups"], list) + for tab in data["tabs"]: + assert isinstance(tab, dict) + assert "name" in tab + assert "fields" in tab + for group in data["groups"]: + assert isinstance(group, dict) + assert "name" in group def test_get_specific_settings_tab(self, protected_api_client: APIClient): """Test getting a specific settings tab.""" @@ -236,20 +263,20 @@ class TestSettingsEndpoint: pytest.skip("Settings disabled") data = resp.json() - if not data: + tabs = data.get("tabs", []) if isinstance(data, dict) else [] + if not tabs: pytest.skip("No settings tabs available") - # Get the first tab - if isinstance(data, list): - tab_name = data[0].get("name") or data[0].get("id") - else: - tab_name = list(data.keys())[0] if data else None - + tab_name = tabs[0].get("name") if not tab_name: pytest.skip("Could not determine tab name") resp = protected_api_client.get(f"/api/settings/{tab_name}") - assert resp.status_code in [200, 404] + assert resp.status_code == 200 + tab_data = resp.json() + assert isinstance(tab_data, dict) + assert tab_data.get("name") == tab_name + assert isinstance(tab_data.get("fields"), list) @pytest.mark.e2e @@ -260,8 +287,9 @@ class TestDownloadFlow: """Test cancelling a download that doesn't exist.""" resp = protected_api_client.delete("/api/download/nonexistent-id-xyz/cancel") - # Should handle gracefully (may return 200, 204, or 404) - assert resp.status_code in [200, 204, 404] + assert resp.status_code == 404 + data = resp.json() + assert data.get("error") == "Failed to cancel download or book not found" @pytest.mark.e2e @@ -270,11 +298,14 @@ class TestReleaseDownloadFlow: def test_release_download_requires_source_id(self, protected_api_client: APIClient): """Test that release download requires source_id.""" - resp = protected_api_client.post("/api/releases/download", json={}) + resp = protected_api_client.post( + "/api/releases/download", + json={"source": "test_source"}, + ) assert resp.status_code == 400 data = resp.json() - assert "error" in data + assert data == {"error": "source_id is required"} def test_release_download_with_minimal_data( self, protected_api_client: APIClient, download_tracker: DownloadTracker @@ -291,10 +322,8 @@ class TestReleaseDownloadFlow: }, ) - if resp.status_code == 200: - download_tracker.track(test_id) - data = resp.json() - assert data.get("status") == "queued" + download_tracker.track(test_id) + assert_queued_download_response(resp) def test_cancel_release_with_slash_id( self, protected_api_client: APIClient, download_tracker: DownloadTracker @@ -315,9 +344,11 @@ class TestReleaseDownloadFlow: pytest.skip("Release download endpoint not available") download_tracker.track(test_id) + assert resp.json() == {"status": "queued", "priority": 0} cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") - assert cancel_resp.status_code in [200, 204] + assert cancel_resp.status_code == 200 + assert cancel_resp.json() == {"status": "cancelled", "book_id": test_id} @pytest.mark.e2e @@ -329,8 +360,7 @@ class TestReleasesSearch: resp = protected_api_client.get("/api/releases") assert resp.status_code == 400 - data = resp.json() - assert "error" in data + assert resp.json() == {"error": "Parameters 'provider' and 'book_id' are required"} def test_releases_with_invalid_provider(self, protected_api_client: APIClient): """Test releases with invalid provider.""" @@ -340,8 +370,7 @@ class TestReleasesSearch: ) assert resp.status_code == 400 - data = resp.json() - assert "error" in data + assert resp.json() == {"error": "Unknown metadata provider: nonexistent_provider"} @pytest.mark.e2e @@ -352,8 +381,11 @@ class TestCoverProxy: """Test that cover endpoint without URL returns error.""" resp = protected_api_client.get("/api/covers/test-id") - # Should return error for missing URL - assert resp.status_code in [400, 404] + assert resp.status_code == 404 + assert resp.json() in [ + {"error": "Cover caching is disabled"}, + {"error": "Cover URL not provided"}, + ] @pytest.mark.e2e @@ -364,7 +396,9 @@ class TestDirectSourceQueryEndpoint: """Source query mode requires a query or browse filters.""" resp = protected_api_client.get("/api/releases", params={"source": "direct_download"}) - assert resp.status_code in [400, 422] + assert resp.status_code == 400 + data = resp.json() + assert data == {"error": "Parameters 'provider' and 'book_id' are required"} def test_direct_source_query_returns_results(self, protected_api_client: APIClient): """Direct mode uses /api/releases source query mode.""" @@ -373,11 +407,20 @@ class TestDirectSourceQueryEndpoint: params={"source": "direct_download", "query": "Pride Prejudice"}, ) - # May return results or 503 if source unavailable if resp.status_code == 200: data = resp.json() - assert data.get("sources_searched") == ["direct_download"] - assert isinstance(data.get("releases"), list) + expected_keys = {"releases", "book", "sources_searched", "column_config", "search_info"} + assert expected_keys <= set(data) + assert data["sources_searched"] == ["direct_download"] + assert isinstance(data["releases"], list) + assert isinstance(data["book"], dict) + assert isinstance(data["search_info"], dict) + if "errors" in data: + assert isinstance(data["errors"], list) + else: + assert resp.status_code == 503 + data = resp.json() + assert "error" in data @pytest.mark.e2e @@ -390,5 +433,7 @@ class TestSourceRecordEndpoint: "/api/release-sources/direct_download/records/invalid-id-xyz" ) - # Should return 404 or error - assert resp.status_code in [404, 500, 503] + if resp.status_code == 503: + pytest.skip("Direct source record lookup unavailable") + assert resp.status_code == 404 + assert resp.json() == {"error": "Record not found"} diff --git a/tests/e2e/test_auth_endpoints.py b/tests/e2e/test_auth_endpoints.py index af2970a..ec0357f 100644 --- a/tests/e2e/test_auth_endpoints.py +++ b/tests/e2e/test_auth_endpoints.py @@ -9,11 +9,13 @@ from __future__ import annotations import importlib import sqlite3 from datetime import UTC, datetime, timedelta -from typing import Any, Tuple +from typing import Any from unittest.mock import Mock, patch import pytest +pytestmark = pytest.mark.e2e + def _as_response(result: Any): """Normalize Flask view return values to a Response-like object.""" @@ -44,31 +46,51 @@ def main_module(): class TestGetAuthMode: def test_get_auth_mode_none(self, main_module): - with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "none"})): + with patch.object( + main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "none"}) + ): assert main_module.get_auth_mode() == "none" def test_get_auth_mode_builtin(self, main_module): - with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "builtin"})): - with patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=True): - assert main_module.get_auth_mode() == "builtin" + with ( + patch.object( + main_module.app_config, + "get", + side_effect=_config_getter({"AUTH_METHOD": "builtin"}), + ), + patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=True), + ): + assert main_module.get_auth_mode() == "builtin" def test_get_auth_mode_builtin_without_local_admin_falls_back_to_none(self, main_module): - with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "builtin"})): - with patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=False): - assert main_module.get_auth_mode() == "none" + with ( + patch.object( + main_module.app_config, + "get", + side_effect=_config_getter({"AUTH_METHOD": "builtin"}), + ), + patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=False), + ): + assert main_module.get_auth_mode() == "none" def test_get_auth_mode_proxy(self, main_module): with patch.object( main_module.app_config, "get", - side_effect=_config_getter({"AUTH_METHOD": "proxy", "PROXY_AUTH_USER_HEADER": "X-Auth-User"}), + side_effect=_config_getter( + {"AUTH_METHOD": "proxy", "PROXY_AUTH_USER_HEADER": "X-Auth-User"} + ), ): assert main_module.get_auth_mode() == "proxy" def test_get_auth_mode_cwa(self, main_module): - with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "cwa"})): - with patch.object(main_module, "CWA_DB_PATH", object()): - assert main_module.get_auth_mode() == "cwa" + with ( + patch.object( + main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "cwa"}) + ), + patch.object(main_module, "CWA_DB_PATH", object()), + ): + assert main_module.get_auth_mode() == "cwa" def test_get_auth_mode_default_on_error(self, main_module): with patch.object(main_module.app_config, "get", side_effect=RuntimeError("boom")): @@ -77,10 +99,12 @@ class TestGetAuthMode: class TestAuthCheckEndpoint: def test_auth_check_no_auth(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="none"): - with main_module.app.test_request_context("/api/auth/check"): - resp = _as_response(main_module.api_auth_check()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="none"), + main_module.app.test_request_context("/api/auth/check"), + ): + resp = _as_response(main_module.api_auth_check()) + data = resp.get_json() assert resp.status_code == 200 assert data == { @@ -91,85 +115,108 @@ class TestAuthCheckEndpoint: } def test_auth_check_builtin_not_authenticated(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/auth/check"): - resp = _as_response(main_module.api_auth_check()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/auth/check"), + ): + resp = _as_response(main_module.api_auth_check()) + data = resp.get_json() assert resp.status_code == 200 - assert data["authenticated"] is False - assert data["auth_required"] is True - assert data["auth_mode"] == "builtin" - assert data["is_admin"] is False - assert data["username"] is None + assert data == { + "authenticated": False, + "auth_required": True, + "auth_mode": "builtin", + "is_admin": False, + "username": None, + "display_name": None, + } def test_auth_check_builtin_authenticated(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/auth/check"): - main_module.session["user_id"] = "admin" - main_module.session["is_admin"] = True - resp = _as_response(main_module.api_auth_check()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/auth/check"), + ): + main_module.session["user_id"] = "admin" + main_module.session["is_admin"] = True + resp = _as_response(main_module.api_auth_check()) + data = resp.get_json() assert resp.status_code == 200 - assert data["authenticated"] is True - assert data["auth_required"] is True - assert data["auth_mode"] == "builtin" - assert data["is_admin"] is True - assert data["username"] == "admin" + assert data == { + "authenticated": True, + "auth_required": True, + "auth_mode": "builtin", + "is_admin": True, + "username": "admin", + "display_name": None, + } def test_auth_check_proxy_includes_logout_url(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", - side_effect=_config_getter({ - "PROXY_AUTH_USER_HEADER": "X-Auth-User", - "PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout", - }), - ): - with main_module.app.test_request_context("/api/auth/check"): - main_module.session["user_id"] = "proxyuser" - main_module.session["is_admin"] = True - resp = _as_response(main_module.api_auth_check()) - data = resp.get_json() + side_effect=_config_getter( + { + "PROXY_AUTH_USER_HEADER": "X-Auth-User", + "PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout", + } + ), + ), + main_module.app.test_request_context("/api/auth/check"), + ): + main_module.session["user_id"] = "proxyuser" + main_module.session["is_admin"] = True + resp = _as_response(main_module.api_auth_check()) + data = resp.get_json() assert resp.status_code == 200 - assert data["authenticated"] is True - assert data["auth_mode"] == "proxy" - assert data["username"] == "proxyuser" - assert data["logout_url"] == "https://auth.example.com/logout" + assert data == { + "authenticated": True, + "auth_required": True, + "auth_mode": "proxy", + "is_admin": True, + "username": "proxyuser", + "display_name": None, + "logout_url": "https://auth.example.com/logout", + } class TestLoginEndpoint: def test_login_proxy_mode_disabled(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with main_module.app.test_request_context( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + main_module.app.test_request_context( "/api/auth/login", method="POST", json={"anything": "x"}, - ): - resp = _as_response(main_module.api_login()) - data = resp.get_json() + ), + ): + resp = _as_response(main_module.api_login()) + data = resp.get_json() assert resp.status_code == 401 - assert "Proxy authentication" in (data.get("error") or "") + assert data == {"error": "Proxy authentication is enabled"} def test_login_no_auth_success(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="none"): - with patch.object(main_module, "is_account_locked", return_value=False): - with main_module.app.test_request_context( - "/api/auth/login", - method="POST", - json={"username": "anyuser", "password": "anypass", "remember_me": True}, - ): - resp = _as_response(main_module.api_login()) - data = resp.get_json() - assert main_module.session.get("user_id") == "anyuser" - assert main_module.session.permanent is True + with ( + patch.object(main_module, "get_auth_mode", return_value="none"), + patch.object(main_module, "is_account_locked", return_value=False), + main_module.app.test_request_context( + "/api/auth/login", + method="POST", + json={"username": "anyuser", "password": "anypass", "remember_me": True}, + ), + ): + resp = _as_response(main_module.api_login()) + data = resp.get_json() + assert main_module.session.get("user_id") == "anyuser" + assert main_module.session.permanent is True assert resp.status_code == 200 - assert data.get("success") is True + assert data == {"success": True} def test_login_builtin_success(self, main_module): mock_user_db = Mock() @@ -179,21 +226,23 @@ class TestLoginEndpoint: "password_hash": "hash", "role": "admin", } - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with patch.object(main_module, "is_account_locked", return_value=False): - with patch.object(main_module, "user_db", mock_user_db): - with patch.object(main_module, "check_password_hash", return_value=True): - with main_module.app.test_request_context( - "/api/auth/login", - method="POST", - json={"username": "admin", "password": "correct", "remember_me": False}, - ): - resp = _as_response(main_module.api_login()) - data = resp.get_json() - assert main_module.session.get("user_id") == "admin" + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + patch.object(main_module, "is_account_locked", return_value=False), + patch.object(main_module, "user_db", mock_user_db), + patch.object(main_module, "check_password_hash", return_value=True), + main_module.app.test_request_context( + "/api/auth/login", + method="POST", + json={"username": "admin", "password": "correct", "remember_me": False}, + ), + ): + resp = _as_response(main_module.api_login()) + data = resp.get_json() + assert main_module.session.get("user_id") == "admin" assert resp.status_code == 200 - assert data.get("success") is True + assert data == {"success": True} def test_login_cwa_provisions_db_user(self, main_module, tmp_path): cwa_db_path = tmp_path / "app.db" @@ -210,23 +259,25 @@ class TestLoginEndpoint: conn.commit() conn.close() - with patch.object(main_module, "get_auth_mode", return_value="cwa"): - with patch.object(main_module, "is_account_locked", return_value=False): - with patch.object(main_module, "CWA_DB_PATH", cwa_db_path): - with patch.object(main_module, "check_password_hash", return_value=True): - with main_module.app.test_request_context( - "/api/auth/login", - method="POST", - json={"username": username, "password": "correct", "remember_me": False}, - ): - resp = _as_response(main_module.api_login()) - data = resp.get_json() - assert main_module.session.get("user_id") == username - assert main_module.session.get("is_admin") is True - assert main_module.session.get("db_user_id") is not None + with ( + patch.object(main_module, "get_auth_mode", return_value="cwa"), + patch.object(main_module, "is_account_locked", return_value=False), + patch.object(main_module, "CWA_DB_PATH", cwa_db_path), + patch.object(main_module, "check_password_hash", return_value=True), + main_module.app.test_request_context( + "/api/auth/login", + method="POST", + json={"username": username, "password": "correct", "remember_me": False}, + ), + ): + resp = _as_response(main_module.api_login()) + data = resp.get_json() + assert main_module.session.get("user_id") == username + assert main_module.session.get("is_admin") is True + assert main_module.session.get("db_user_id") is not None assert resp.status_code == 200 - assert data.get("success") is True + assert data == {"success": True} db_user = main_module.user_db.get_user(username=username) assert db_user["email"] == "cwa@example.com" assert db_user["role"] == "admin" @@ -255,22 +306,24 @@ class TestLoginEndpoint: conn.commit() conn.close() - with patch.object(main_module, "get_auth_mode", return_value="cwa"): - with patch.object(main_module, "is_account_locked", return_value=False): - with patch.object(main_module, "CWA_DB_PATH", cwa_db_path): - with patch.object(main_module, "check_password_hash", return_value=True): - with main_module.app.test_request_context( - "/api/auth/login", - method="POST", - json={"username": username, "password": "correct", "remember_me": False}, - ): - resp = _as_response(main_module.api_login()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="cwa"), + patch.object(main_module, "is_account_locked", return_value=False), + patch.object(main_module, "CWA_DB_PATH", cwa_db_path), + patch.object(main_module, "check_password_hash", return_value=True), + main_module.app.test_request_context( + "/api/auth/login", + method="POST", + json={"username": username, "password": "correct", "remember_me": False}, + ), + ): + resp = _as_response(main_module.api_login()) + data = resp.get_json() - assert resp.status_code == 200 - assert data.get("success") is True - assert main_module.session.get("user_id") == username - assert main_module.session.get("db_user_id") is not None + assert resp.status_code == 200 + assert data == {"success": True} + assert main_module.session.get("user_id") == username + assert main_module.session.get("db_user_id") is not None local_after = main_module.user_db.get_user(user_id=local_user["id"]) assert local_after is not None @@ -278,7 +331,8 @@ class TestLoginEndpoint: assert local_after["email"] == "collision.local@example.com" provisioned_cwa_user = next( - user for user in main_module.user_db.list_users() + user + for user in main_module.user_db.list_users() if user.get("auth_source") == "cwa" and user.get("email") == external_email ) assert provisioned_cwa_user["username"].startswith(f"{username}__cwa") @@ -286,31 +340,40 @@ class TestLoginEndpoint: class TestLogoutEndpoint: def test_logout_proxy_returns_logout_url(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", - side_effect=_config_getter({"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout"}), - ): - with main_module.app.test_request_context("/api/auth/logout", method="POST"): - main_module.session["user_id"] = "proxyuser" - resp = _as_response(main_module.api_logout()) - data = resp.get_json() + side_effect=_config_getter( + {"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout"} + ), + ), + main_module.app.test_request_context("/api/auth/logout", method="POST"), + ): + main_module.session["user_id"] = "proxyuser" + resp = _as_response(main_module.api_logout()) + data = resp.get_json() + assert "user_id" not in main_module.session assert resp.status_code == 200 - assert data["success"] is True - assert data["logout_url"] == "https://auth.example.com/logout" + assert data == { + "success": True, + "logout_url": "https://auth.example.com/logout", + } def test_logout_basic(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/auth/logout", method="POST"): - main_module.session["user_id"] = "admin" - resp = _as_response(main_module.api_logout()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/auth/logout", method="POST"), + ): + main_module.session["user_id"] = "admin" + resp = _as_response(main_module.api_logout()) + data = resp.get_json() + assert "user_id" not in main_module.session assert resp.status_code == 200 - assert data["success"] is True - assert "logout_url" not in data + assert data == {"success": True} class TestRateLimiting: diff --git a/tests/e2e/test_auth_flow.py b/tests/e2e/test_auth_flow.py index 50113f2..d1ef287 100644 --- a/tests/e2e/test_auth_flow.py +++ b/tests/e2e/test_auth_flow.py @@ -7,9 +7,26 @@ with various authentication modes. Run with: uv run pytest tests/e2e/ -v -m e2e """ +from typing import TYPE_CHECKING +from uuid import uuid4 + import pytest -from .conftest import APIClient +if TYPE_CHECKING: + from .conftest import APIClient + + +def _auth_check(api_client: APIClient) -> dict: + """Fetch the current auth state and assert the response shape.""" + resp = api_client.get("/api/auth/check") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) + assert "authenticated" in data + assert "auth_required" in data + assert "auth_mode" in data + assert "is_admin" in data + return data @pytest.mark.e2e @@ -17,76 +34,104 @@ class TestAuthenticationFlow: """Tests for the authentication endpoints in a real environment.""" def test_auth_check_endpoint_exists(self, api_client: APIClient): - """Test that auth check endpoint is accessible.""" - resp = api_client.get("/api/auth/check") + """Test that auth check returns the stable contract fields.""" + data = _auth_check(api_client) - assert resp.status_code == 200 - data = resp.json() - assert "authenticated" in data - assert "auth_required" in data - assert "auth_mode" in data + if data["auth_mode"] == "none": + assert data == { + "authenticated": True, + "auth_required": False, + "auth_mode": "none", + "is_admin": True, + } + return + + assert isinstance(data["authenticated"], bool) + assert data["auth_required"] is True + assert data["auth_mode"] in ["builtin", "cwa", "proxy", "oidc"] + assert isinstance(data["is_admin"], bool) + assert data["username"] is None or isinstance(data["username"], str) + assert "display_name" in data + assert data["display_name"] is None or isinstance(data["display_name"], str) def test_auth_check_returns_auth_mode(self, api_client: APIClient): - """Test that auth check returns the current auth mode.""" - resp = api_client.get("/api/auth/check") - - data = resp.json() - assert "auth_mode" in data - # Should be one of the valid auth modes + """Test that auth check reports a known auth mode.""" + data = _auth_check(api_client) assert data["auth_mode"] in ["none", "builtin", "cwa", "proxy", "oidc"] def test_auth_check_includes_admin_status(self, api_client: APIClient): - """Test that auth check includes admin status.""" - resp = api_client.get("/api/auth/check") - - data = resp.json() - assert "is_admin" in data + """Test that auth check exposes a boolean admin flag.""" + data = _auth_check(api_client) assert isinstance(data["is_admin"], bool) def test_logout_endpoint_exists(self, api_client: APIClient): - """Test that logout endpoint is accessible.""" + """Test that logout returns the stable success contract.""" resp = api_client.post("/api/auth/logout") - # Should return 200 whether authenticated or not assert resp.status_code == 200 data = resp.json() - assert "success" in data + assert data.get("success") is True + assert set(data).issubset({"success", "logout_url"}) + if "logout_url" in data: + assert isinstance(data["logout_url"], str) + assert data["logout_url"].startswith("http") def test_logout_may_return_logout_url(self, api_client: APIClient): """Test that logout may return a logout URL for proxy auth.""" resp = api_client.post("/api/auth/logout") data = resp.json() - # logout_url is optional depending on auth mode if "logout_url" in data: assert isinstance(data["logout_url"], str) + assert data["logout_url"].startswith("http") def test_login_endpoint_exists(self, api_client: APIClient): - """Test that login endpoint is accessible.""" + """Test that login obeys the current authentication contract.""" + auth_data = _auth_check(api_client) + username = f"e2e-auth-{uuid4().hex[:8]}" resp = api_client.post( - "/api/auth/login", json={"username": "test", "password": "test", "remember_me": False} + "/api/auth/login", + json={"username": username, "password": "wrong-password", "remember_me": False}, ) - # Should return some response (may be success, auth error, or rate limit) - assert resp.status_code in [200, 401, 403, 429] + auth_mode = auth_data.get("auth_mode") + if auth_mode == "none": + assert resp.status_code == 200 + assert resp.json() == {"success": True} + elif auth_mode == "proxy": + assert resp.status_code == 401 + assert resp.json() == {"error": "Proxy authentication is enabled"} + elif auth_mode in {"builtin", "cwa"}: + assert resp.status_code == 401 + assert resp.json() == {"error": "Invalid username or password."} + elif auth_mode == "oidc": + if auth_data.get("hide_local_auth"): + assert resp.status_code == 403 + assert resp.json() == {"error": "Local authentication is disabled"} + else: + assert resp.status_code == 401 + assert resp.json() == {"error": "Invalid username or password."} + else: + pytest.fail(f"Unexpected auth mode: {auth_mode}") def test_login_with_no_auth_succeeds(self, api_client: APIClient): """Test that login succeeds when no authentication is required.""" - # First check if auth is required - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + auth_data = _auth_check(api_client) if not auth_data.get("auth_required"): - # Try logging in resp = api_client.post( "/api/auth/login", json={"username": "anyuser", "password": "anypass", "remember_me": False}, ) - # Should succeed assert resp.status_code == 200 - data = resp.json() - assert data.get("success") is True + assert resp.json() == {"success": True} + assert api_client.get("/api/auth/check").json() == { + "authenticated": True, + "auth_required": False, + "auth_mode": "none", + "is_admin": True, + } @pytest.mark.e2e @@ -94,34 +139,35 @@ class TestProxyAuthentication: """Tests for proxy authentication mode.""" def test_proxy_auth_with_valid_header(self, api_client: APIClient): - """Test proxy auth when valid user header is present.""" - # Check current auth mode - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + """Test proxy auth creates and preserves a session from the proxy header.""" + auth_data = _auth_check(api_client) if auth_data.get("auth_mode") != "proxy": pytest.skip("Proxy authentication not configured") - # Make a request with proxy auth header - # Note: In real deployment, these headers would be set by the proxy - resp = api_client.get("/api/config", headers={"X-Auth-User": "proxyuser"}) - - if resp.status_code == 401: - pytest.skip("Proxy auth header not accepted (check proxy configuration)") - - # Should be able to access the endpoint + resp = api_client.get("/api/auth/check", headers={"X-Auth-User": "proxyuser"}) assert resp.status_code == 200 + data = resp.json() + assert data["authenticated"] is True + assert data["auth_mode"] == "proxy" + assert data["username"] == "proxyuser" + assert data["auth_required"] is True + assert isinstance(data["is_admin"], bool) + assert "display_name" in data + + follow_up = api_client.get("/api/auth/check") + follow_up_data = follow_up.json() + assert follow_up.status_code == 200 + assert follow_up_data["authenticated"] is True + assert follow_up_data["username"] == "proxyuser" def test_proxy_auth_logout_url_available(self, api_client: APIClient): """Test that proxy auth provides logout URL if configured.""" - # Check current auth mode - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + auth_data = _auth_check(api_client) if auth_data.get("auth_mode") != "proxy": pytest.skip("Proxy authentication not configured") - # Check for logout URL in auth check response if "logout_url" in auth_data: assert isinstance(auth_data["logout_url"], str) assert len(auth_data["logout_url"]) > 0 @@ -133,39 +179,32 @@ class TestBuiltinAuthentication: def test_builtin_auth_requires_credentials(self, api_client: APIClient): """Test that endpoints require authentication when builtin auth is enabled.""" - # Check current auth mode - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + auth_data = _auth_check(api_client) if auth_data.get("auth_mode") != "builtin": pytest.skip("Built-in authentication not configured") - if not auth_data.get("authenticated"): - # Attempt to access protected endpoint without authentication - resp = api_client.get("/api/config") + if auth_data.get("authenticated"): + pytest.skip("Built-in auth session already authenticated") - # Should be blocked - assert resp.status_code == 401 + resp = api_client.get("/api/config") + assert resp.status_code == 401 def test_builtin_auth_invalid_credentials(self, api_client: APIClient): """Test login with invalid credentials fails.""" - # Check current auth mode - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + auth_data = _auth_check(api_client) if auth_data.get("auth_mode") != "builtin": pytest.skip("Built-in authentication not configured") - # Try logging in with invalid credentials + username = f"builtin-e2e-{uuid4().hex[:8]}" resp = api_client.post( "/api/auth/login", - json={"username": "invalid_user", "password": "wrong_password", "remember_me": False}, + json={"username": username, "password": "wrong_password", "remember_me": False}, ) - # Should fail, or be rate-limited on a live stack after repeated attempts - assert resp.status_code in [401, 403, 429] - data = resp.json() - assert data.get("success") is not True + assert resp.status_code == 401 + assert resp.json() == {"error": "Invalid username or password."} @pytest.mark.e2e @@ -174,53 +213,29 @@ class TestCalibreWebAuthentication: def test_cwa_auth_mode_available(self, api_client: APIClient): """Test that CWA auth mode is reported if configured.""" - # Check current auth mode - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + auth_data = _auth_check(api_client) if auth_data.get("auth_mode") == "cwa": - # CWA mode is active assert auth_data["auth_mode"] == "cwa" - # Should have authenticated or auth_required status - assert "authenticated" in auth_data - assert "auth_required" in auth_data + assert auth_data["auth_required"] is True + assert isinstance(auth_data["authenticated"], bool) + assert isinstance(auth_data["is_admin"], bool) @pytest.mark.e2e class TestAdminAccess: """Tests for admin access restrictions.""" - def test_settings_endpoint_respects_admin_restriction(self, api_client: APIClient): - """Test that settings endpoints respect admin restrictions.""" - # Check current auth status - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() + def test_admin_only_routes_require_auth(self, api_client: APIClient): + """Test that admin-only routes are blocked before auth is established.""" + auth_data = _auth_check(api_client) - # If auth is required and user is not admin - if auth_data.get("auth_required") and auth_data.get("authenticated"): - if not auth_data.get("is_admin"): - # Try accessing settings - resp = api_client.get("/api/settings") + if not auth_data.get("auth_required"): + pytest.skip("Authentication is not required in this environment") - # May be blocked with 403 if admin-only - # Or allowed if settings are not restricted - assert resp.status_code in [200, 403] - - def test_onboarding_endpoint_respects_admin_restriction(self, api_client: APIClient): - """Test that onboarding endpoints respect admin restrictions.""" - # Check current auth status - auth_check = api_client.get("/api/auth/check") - auth_data = auth_check.json() - - # If auth is required and user is not admin - if auth_data.get("auth_required") and auth_data.get("authenticated"): - if not auth_data.get("is_admin"): - # Try accessing onboarding - resp = api_client.get("/api/onboarding") - - # May be blocked with 403 if admin-only - # Or allowed if settings are not restricted - assert resp.status_code in [200, 403] + for path in ("/api/settings/security", "/api/settings/users", "/api/onboarding"): + resp = api_client.get(path) + assert resp.status_code == 401 @pytest.mark.e2e @@ -229,45 +244,28 @@ class TestAuthenticationWorkflow: def test_login_logout_cycle(self, api_client: APIClient): """Test complete login and logout cycle.""" - # Check initial auth status - auth_check = api_client.get("/api/auth/check") - initial_auth = auth_check.json() + initial_auth = _auth_check(api_client) - # If no auth required, skip this test if not initial_auth.get("auth_required"): pytest.skip("No authentication required") - # Try logout first to clear any existing session logout_resp = api_client.post("/api/auth/logout") assert logout_resp.status_code == 200 + assert logout_resp.json().get("success") is True - # Check we're logged out - auth_check = api_client.get("/api/auth/check") - post_logout_auth = auth_check.json() + post_logout_auth = _auth_check(api_client) - # For builtin/cwa auth, should not be authenticated - # For proxy auth, depends on proxy configuration - if initial_auth.get("auth_mode") in ["builtin", "cwa"]: + if ( + initial_auth.get("auth_mode") in ["builtin", "cwa"] + or initial_auth.get("auth_mode") == "proxy" + ): assert post_logout_auth.get("authenticated") is False + assert post_logout_auth.get("username") is None def test_auth_check_consistency(self, api_client: APIClient): """Test that auth check returns consistent results.""" - # Make multiple auth check requests - resp1 = api_client.get("/api/auth/check") - resp2 = api_client.get("/api/auth/check") - resp3 = api_client.get("/api/auth/check") + data1 = _auth_check(api_client) + data2 = _auth_check(api_client) + data3 = _auth_check(api_client) - data1 = resp1.json() - data2 = resp2.json() - data3 = resp3.json() - - # All should succeed - assert resp1.status_code == 200 - assert resp2.status_code == 200 - assert resp3.status_code == 200 - - # Auth mode should be consistent - assert data1["auth_mode"] == data2["auth_mode"] == data3["auth_mode"] - - # Auth required should be consistent - assert data1["auth_required"] == data2["auth_required"] == data3["auth_required"] + assert data1 == data2 == data3 diff --git a/tests/e2e/test_conftest_helpers.py b/tests/e2e/test_conftest_helpers.py index bd4f813..2937a50 100644 --- a/tests/e2e/test_conftest_helpers.py +++ b/tests/e2e/test_conftest_helpers.py @@ -4,26 +4,195 @@ from _pytest.outcomes import Failed, Skipped from tests.e2e import conftest as e2e_conftest +class DummyResponse: + def __init__(self, status_code: int, payload: object, text: str = "") -> None: + self.status_code = status_code + self._payload = payload + self.text = text or repr(payload) + + def json(self) -> object: + return self._payload + + +class RaisingResponse(DummyResponse): + def __init__(self, status_code: int, exc: Exception, text: str = "") -> None: + super().__init__(status_code=status_code, payload=None, text=text) + self._exc = exc + + def json(self) -> object: + raise self._exc + + +def _make_client() -> e2e_conftest.APIClient: + return e2e_conftest.APIClient(base_url="http://example.com") + + +def test_api_client_wait_for_health_retries_through_request_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = _make_client() + calls = {"count": 0} + + def fake_get(_path: str, **_kwargs: object) -> DummyResponse: + calls["count"] += 1 + if calls["count"] == 1: + raise e2e_conftest.requests.exceptions.ReadTimeout("timeout") + return DummyResponse(200, {"status": "ok"}) + + times = [0.0, 0.0, 0.1] + + monkeypatch.setattr(client, "get", fake_get) + monkeypatch.setattr(e2e_conftest.time, "time", lambda: times.pop(0)) + monkeypatch.setattr(e2e_conftest.time, "sleep", lambda _seconds: None) + + try: + assert client.wait_for_health(max_wait=1) is True + assert calls["count"] == 2 + finally: + client.close() + + +def test_download_tracker_wait_for_status_ignores_malformed_payloads_and_returns_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeClient: + def __init__(self) -> None: + self.responses = [ + DummyResponse(200, ["not", "a", "mapping"]), + DummyResponse(200, {"error": {"task-1": {"message": "boom"}}}), + ] + + def get(self, _path: str) -> DummyResponse: + return self.responses.pop(0) + + tracker = e2e_conftest.DownloadTracker(client=FakeClient()) + times = [0.0, 0.0, 0.1] + + monkeypatch.setattr(e2e_conftest.time, "time", lambda: times.pop(0)) + monkeypatch.setattr(e2e_conftest.time, "sleep", lambda _seconds: None) + + result = tracker.wait_for_status("task-1", ["complete"], timeout=1) + + assert result == {"state": "error", "data": {"message": "boom"}} + + +def test_assert_json_object_returns_dict() -> None: + response = DummyResponse(200, {"status": "ok"}) + + assert e2e_conftest.assert_json_object(response, context="health") == {"status": "ok"} + + +def test_assert_json_object_rejects_non_object_payload() -> None: + response = DummyResponse(200, ["not", "an", "object"]) + + with pytest.raises(AssertionError, match="did not return a JSON object"): + e2e_conftest.assert_json_object(response, context="health") + + +def test_assert_json_object_rejects_invalid_json() -> None: + response = RaisingResponse(200, ValueError("bad json")) + + with pytest.raises(Failed, match="did not return valid JSON"): + e2e_conftest.assert_json_object(response, context="health") + + +def test_assert_json_list_returns_list() -> None: + response = DummyResponse(200, [1, 2, 3]) + + assert e2e_conftest.assert_json_list(response, context="queue") == [1, 2, 3] + + +def test_assert_queue_order_response_validates_entries() -> None: + response = DummyResponse( + 200, + { + "queue": [ + { + "id": "task-1", + "priority": 0, + "added_time": 12.5, + "status": "queued", + } + ] + }, + ) + + queue = e2e_conftest.assert_queue_order_response(response) + assert queue[0]["id"] == "task-1" + + +def test_assert_queue_order_response_rejects_missing_entry_fields() -> None: + response = DummyResponse(200, {"queue": [{"id": "task-1"}]}) + + with pytest.raises(AssertionError): + e2e_conftest.assert_queue_order_response(response) + + def test_require_authenticated_client_allows_public_server( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = e2e_conftest.APIClient(base_url="http://example.com") - monkeypatch.setattr( - e2e_conftest, - "_get_auth_state", - lambda _: {"auth_required": False}, - ) + client = _make_client() + monkeypatch.setattr(e2e_conftest, "_get_auth_state", lambda _: {"auth_required": False}) try: assert e2e_conftest._require_authenticated_client(client, strict=True) is client finally: - client.session.close() + client.close() + + +def test_require_authenticated_client_fails_when_auth_state_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = _make_client() + monkeypatch.setattr(e2e_conftest, "_get_auth_state", lambda _: None) + + try: + with pytest.raises(Failed, match="Unable to read auth state"): + e2e_conftest._require_authenticated_client(client, strict=True) + finally: + client.close() + + +def test_require_healthy_server_fails_in_strict_runs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed: list[str] = [] + + monkeypatch.setattr(e2e_conftest.APIClient, "wait_for_health", lambda self, max_wait=30: False) + monkeypatch.setattr(e2e_conftest.APIClient, "close", lambda self: closed.append(self.base_url)) + + with pytest.raises(Failed, match="Server not available"): + e2e_conftest._require_healthy_server("http://example.com", strict=True) + + assert closed == ["http://example.com"] + + +def test_require_healthy_server_skips_in_non_strict_runs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(e2e_conftest.APIClient, "wait_for_health", lambda self, max_wait=30: False) + + with pytest.raises(Skipped, match="Server not available"): + e2e_conftest._require_healthy_server("http://example.com", strict=False) + + +def test_require_authenticated_client_skips_when_auth_state_unavailable_and_not_strict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = _make_client() + monkeypatch.setattr(e2e_conftest, "_get_auth_state", lambda _: None) + + try: + with pytest.raises(Skipped, match="Unable to read auth state"): + e2e_conftest._require_authenticated_client(client, strict=False) + finally: + client.close() def test_require_authenticated_client_fails_without_env_credentials( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = e2e_conftest.APIClient(base_url="http://example.com") + client = _make_client() monkeypatch.setattr( e2e_conftest, "_get_auth_state", @@ -36,13 +205,13 @@ def test_require_authenticated_client_fails_without_env_credentials( with pytest.raises(Failed, match="requires authentication"): e2e_conftest._require_authenticated_client(client, strict=True) finally: - client.session.close() + client.close() def test_require_authenticated_client_skips_without_env_credentials_when_not_strict( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = e2e_conftest.APIClient(base_url="http://example.com") + client = _make_client() monkeypatch.setattr( e2e_conftest, "_get_auth_state", @@ -55,13 +224,13 @@ def test_require_authenticated_client_skips_without_env_credentials_when_not_str with pytest.raises(Skipped, match="requires authentication"): e2e_conftest._require_authenticated_client(client, strict=False) finally: - client.session.close() + client.close() def test_require_authenticated_client_logs_in_when_credentials_are_available( monkeypatch: pytest.MonkeyPatch, ) -> None: - client = e2e_conftest.APIClient(base_url="http://example.com") + client = _make_client() auth_states = iter( [ {"auth_required": True, "authenticated": False}, @@ -76,7 +245,7 @@ def test_require_authenticated_client_logs_in_when_credentials_are_available( try: assert e2e_conftest._require_authenticated_client(client, strict=True) is client finally: - client.session.close() + client.close() def test_is_explicit_e2e_run_detects_e2e_path_selection() -> None: @@ -86,6 +255,16 @@ def test_is_explicit_e2e_run_detects_e2e_path_selection() -> None: ) +def test_is_explicit_e2e_run_detects_mixed_path_selection() -> None: + assert ( + e2e_conftest._is_explicit_e2e_run( + "", + ["tests/e2e/test_api.py", "tests/core/test_user_db.py"], + ) + is True + ) + + def test_is_explicit_e2e_run_detects_markexpr_selection() -> None: assert e2e_conftest._is_explicit_e2e_run("e2e", ["tests/"]) is True assert e2e_conftest._is_explicit_e2e_run("slow and e2e", ["tests/"]) is True diff --git a/tests/e2e/test_download_flow.py b/tests/e2e/test_download_flow.py index 8bb8a99..75e9835 100644 --- a/tests/e2e/test_download_flow.py +++ b/tests/e2e/test_download_flow.py @@ -7,59 +7,204 @@ They require external services to be available and may take longer to run. Run with: uv run pytest tests/e2e/test_download_flow.py -v -m e2e """ -import os -import hashlib import time import pytest -from .conftest import APIClient, DownloadTracker, DOWNLOAD_TIMEOUT +from .conftest import ( + DOWNLOAD_TIMEOUT, + SUCCESS_DOWNLOAD_STATES, + APIClient, + DownloadTracker, + assert_queue_order_response, + assert_queued_download_response, +) + + +def _assert_terminal_download_result( + result: dict[str, object], + *, + source_id: str, + expected_title: str, + expected_source: str | None = None, +) -> None: + """Assert that a finished download produced a structured queue payload.""" + state = result["state"] + entry = result["data"] + assert isinstance(entry, dict) + if state == "error": + error_message = str( + entry.get("status_message") or entry.get("last_error_message") or "" + ).strip() + pytest.fail( + f"{expected_source or source_id} download failed" + f"{f': {error_message}' if error_message else f': {entry!r}'}" + ) + + assert state in SUCCESS_DOWNLOAD_STATES, ( + f"{expected_source or source_id} ended in unexpected state {state!r}: {entry!r}" + ) + assert entry.get("id") == source_id + assert entry.get("title") == expected_title + if expected_source is not None: + assert entry.get("source") == expected_source + status = entry.get("status") + assert status is None or status in SUCCESS_DOWNLOAD_STATES | {"queued"} + + +def _is_duplicate_queue_error(response) -> bool: + """Whether the API refused to queue a release because it already exists.""" + if response.status_code != 500: + return False + try: + payload = response.json() + except ValueError: + return False + return payload == {"error": "Release is already in the download queue"} + + +def _require_json_object( + response, *, context: str, skip_statuses: set[int] | frozenset[int] = frozenset({503}) +) -> dict[str, object]: + if response.status_code in skip_statuses: + pytest.skip(f"{context} unavailable: {response.status_code}") + assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" + payload = response.json() + assert isinstance(payload, dict), f"{context} did not return a JSON object: {payload!r}" + return payload + + +def _extract_result_list(payload: object, *, context: str) -> list[dict[str, object]]: + if isinstance(payload, dict): + if "books" in payload: + results = payload["books"] + elif "releases" in payload: + results = payload["releases"] + else: + results = payload.get("results", payload) + else: + results = payload + + if isinstance(results, dict): + list_values = [value for value in results.values() if isinstance(value, list)] + if not list_values: + pytest.fail(f"{context} returned an unexpected result structure: {payload!r}") + if not any(list_values): + pytest.skip(f"{context} returned no results") + results = next(value for value in list_values if value) + + if not isinstance(results, list): + pytest.fail(f"{context} did not return a result list: {payload!r}") + if not results: + pytest.skip(f"{context} returned no results") + return results + + +def _require_queue_entry( + api_client: APIClient, + book_id: str, + *, + context: str, + timeout: int = 20, +) -> dict[str, object]: + deadline = time.time() + timeout + while time.time() < deadline: + queue_resp = api_client.get("/api/queue/order") + if queue_resp.status_code == 200: + queue_order = assert_queue_order_response(queue_resp) + for entry in queue_order: + if entry.get("id") == book_id: + return entry + elif queue_resp.status_code == 503: + pytest.skip(f"{context} queue endpoint unavailable") + else: + pytest.fail( + f"{context} queue lookup failed: {queue_resp.status_code} {queue_resp.text}" + ) + + status_resp = api_client.get("/api/status") + if status_resp.status_code == 200: + status_data = status_resp.json() + if isinstance(status_data, dict): + for state in ("complete", "done", "available", "error", "cancelled"): + state_entries = status_data.get(state) + if isinstance(state_entries, dict) and book_id in state_entries: + pytest.fail( + f"{context} reached terminal state {state} before it was observed in the queue: " + f"{state_entries[book_id]!r}" + ) + + time.sleep(1) + + pytest.fail(f"{context} never appeared in the queue") + + +def _wait_for_queue_absence( + api_client: APIClient, + book_id: str, + *, + context: str, + timeout: int = 20, +) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + queue_resp = api_client.get("/api/queue/order") + if queue_resp.status_code == 200: + queue_order = assert_queue_order_response(queue_resp) + if all(entry.get("id") != book_id for entry in queue_order): + return + elif queue_resp.status_code == 503: + pytest.skip(f"{context} queue endpoint unavailable") + else: + pytest.fail( + f"{context} queue lookup failed: {queue_resp.status_code} {queue_resp.text}" + ) + + time.sleep(1) + + pytest.fail(f"{context} still appeared in the queue after cancellation") def _find_available_provider(api_client: APIClient) -> str | None: """Find a working metadata provider.""" resp = api_client.get("/api/metadata/providers") - if resp.status_code != 200: - return None + if resp.status_code == 503: + pytest.skip("Metadata providers unavailable") + assert resp.status_code == 200, f"Metadata providers failed: {resp.status_code} {resp.text}" providers_data = resp.json() + if not isinstance(providers_data, (dict, list)): + pytest.fail(f"Metadata providers returned an unexpected payload: {providers_data!r}") - # Handle both dict and list formats - if isinstance(providers_data, dict): - # Dict format: keys are provider names - provider_names = list(providers_data.keys()) - else: - # List format - provider_names = [ - p.get("name") for p in providers_data if isinstance(p, dict) and p.get("name") - ] + providers = ( + providers_data.get("providers", []) if isinstance(providers_data, dict) else providers_data + ) + provider_names = [ + provider.get("name") + for provider in providers + if ( + isinstance(provider, dict) + and provider.get("name") + and provider.get("enabled") is True + and provider.get("available") is True + ) + ] for name in provider_names: - if name: - # Try a simple search to verify it works - test_resp = api_client.get( - "/api/metadata/search", - params={"query": "test", "provider": name}, - timeout=30, - ) - if test_resp.status_code == 200: - return name - return None - - -def _find_available_release_source(api_client: APIClient) -> str | None: - """Find a working release source.""" - resp = api_client.get("/api/release-sources") - if resp.status_code != 200: - return None - - sources = resp.json() - for source in sources: - name = source.get("name") - # Skip prowlarr unless configured - if name and name != "prowlarr": + test_resp = api_client.get( + "/api/metadata/search", + params={"query": "test", "provider": name}, + timeout=30, + ) + if test_resp.status_code == 200: return name - return None + if test_resp.status_code != 503: + pytest.fail( + f"Metadata provider {name} failed during availability check: " + f"{test_resp.status_code} {test_resp.text}" + ) + + pytest.skip("No working metadata providers available") @pytest.mark.e2e @@ -71,8 +216,6 @@ class TestMetadataToReleaseFlow: """Test searching metadata then finding releases.""" # Find a working provider provider = _find_available_provider(protected_api_client) - if not provider: - pytest.skip("No metadata providers available") # Search for a public domain book search_resp = protected_api_client.get( @@ -81,22 +224,8 @@ class TestMetadataToReleaseFlow: timeout=30, ) - if search_resp.status_code != 200: - pytest.skip(f"Search failed: {search_resp.status_code}") - - search_data = search_resp.json() - results = search_data.get("results", search_data) - - # Handle dict format where results might be nested - if isinstance(results, dict): - # Results might be under a key like the query or "results" - for key, value in results.items(): - if isinstance(value, list) and value: - results = value - break - - if not results or not isinstance(results, list): - pytest.skip("No search results returned") + search_data = _require_json_object(search_resp, context="metadata search") + results = _extract_result_list(search_data, context="metadata search") # Get the first result first_result = results[0] @@ -115,11 +244,13 @@ class TestMetadataToReleaseFlow: timeout=60, ) - # Releases may fail if sources are unavailable - if releases_resp.status_code == 200: - releases_data = releases_resp.json() - assert "releases" in releases_data - assert "book" in releases_data + releases_data = _require_json_object(releases_resp, context="release lookup") + releases = releases_data.get("releases") + if not isinstance(releases, list): + pytest.fail(f"Release lookup returned an invalid releases payload: {releases_data!r}") + if not releases: + pytest.skip("No releases available") + assert "book" in releases_data @pytest.mark.e2e @@ -152,21 +283,8 @@ class TestFullDownloadJourney: timeout=30, ) - if search_resp.status_code != 200: - pytest.skip(f"Metadata search unavailable: {search_resp.status_code}") - - search_data = search_resp.json() - results = search_data.get("results", search_data) - - # Handle dict format where results might be nested - if isinstance(results, dict): - for key, value in results.items(): - if isinstance(value, list) and value: - results = value - break - - if not results or not isinstance(results, list): - pytest.skip("No search results") + search_data = _require_json_object(search_resp, context="metadata search") + results = _extract_result_list(search_data, context="metadata search") first_result = results[0] book_id = first_result.get("id") or first_result.get("provider_id") @@ -182,12 +300,8 @@ class TestFullDownloadJourney: timeout=60, ) - if releases_resp.status_code != 200: - pytest.skip(f"Releases unavailable: {releases_resp.status_code}") - - releases_data = releases_resp.json() + releases_data = _require_json_object(releases_resp, context="release lookup") releases = releases_data.get("releases", []) - if not releases: pytest.skip("No releases available") @@ -218,9 +332,8 @@ class TestFullDownloadJourney: }, ) - assert queue_resp.status_code == 200, f"Failed to queue: {queue_resp.text}" - queue_data = queue_resp.json() - assert queue_data.get("status") == "queued" + if not _is_duplicate_queue_error(queue_resp): + assert_queued_download_response(queue_resp) # Wait for download to complete (or error) result = download_tracker.wait_for_status( @@ -234,12 +347,17 @@ class TestFullDownloadJourney: status_resp = protected_api_client.get("/api/status") if status_resp.status_code == 200: status_data = status_resp.json() - if "error" in status_data and source_id in status_data["error"]: - error_info = status_data["error"][source_id] - pytest.skip(f"Download failed: {error_info}") + error_info = status_data.get("error", {}).get(source_id) + if error_info: + pytest.fail(f"Download failed: {error_info}") pytest.fail("Download timed out") - assert result["state"] in ["complete", "done", "available"] + _assert_terminal_download_result( + result, + source_id=source_id, + expected_title=target_release.get("title", "Test Book"), + expected_source=target_release.get("source", "direct_download"), + ) @pytest.mark.e2e @@ -260,11 +378,17 @@ class TestDirectSourceReleaseFlow: if search_resp.status_code == 503: pytest.skip("Direct source query unavailable") - if search_resp.status_code != 200: - pytest.skip(f"Direct source query failed: {search_resp.status_code}") + assert search_resp.status_code == 200, ( + f"Direct source query failed: {search_resp.status_code} {search_resp.text}" + ) payload = search_resp.json() + assert isinstance(payload, dict), ( + f"Direct source query returned an unexpected payload: {payload!r}" + ) results = payload.get("releases") or [] + if not isinstance(results, list): + pytest.fail(f"Direct source query returned an unexpected payload: {payload!r}") if not results: pytest.skip("No direct source query results") @@ -277,7 +401,7 @@ class TestDirectSourceReleaseFlow: info_resp = protected_api_client.get(f"/api/release-sources/{source}/records/{source_id}") if info_resp.status_code != 200: - pytest.skip(f"Source record endpoint failed: {info_resp.status_code}") + pytest.fail(f"Source record endpoint failed: {info_resp.status_code}") # Queue download from the shared release payload download_tracker.track(source_id) @@ -286,11 +410,21 @@ class TestDirectSourceReleaseFlow: json={**first_result, "content_type": "ebook", "search_mode": "direct"}, ) - if download_resp.status_code != 200: - pytest.skip(f"Release download queue failed: {download_resp.status_code}") + if not _is_duplicate_queue_error(download_resp): + assert_queued_download_response(download_resp) - download_data = download_resp.json() - assert download_data.get("status") == "queued" + result = download_tracker.wait_for_status( + source_id, + target_states=["complete", "done", "available"], + timeout=DOWNLOAD_TIMEOUT, + ) + assert result is not None, "Direct source download did not reach a terminal state" + _assert_terminal_download_result( + result, + source_id=source_id, + expected_title=first_result.get("title", "Unknown title"), + expected_source="direct_download", + ) @pytest.mark.e2e @@ -314,16 +448,26 @@ class TestDownloadCancellation: }, ) - if queue_resp.status_code != 200: - pytest.skip("Could not queue test download") + assert_queued_download_response(queue_resp) - # Give it a moment - time.sleep(1) + _require_queue_entry( + protected_api_client, + test_id, + context="cancel download precondition", + ) # Cancel it cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") - assert cancel_resp.status_code in [200, 204] + assert cancel_resp.status_code == 200 + cancel_data = cancel_resp.json() + assert cancel_data == {"status": "cancelled", "book_id": test_id} + + _wait_for_queue_absence( + protected_api_client, + test_id, + context="cancel download", + ) def test_cancel_removes_from_queue( self, protected_api_client: APIClient, download_tracker: DownloadTracker @@ -342,18 +486,23 @@ class TestDownloadCancellation: }, ) - time.sleep(0.5) + _require_queue_entry( + protected_api_client, + test_id, + context="cancel verification precondition", + ) # Cancel it - protected_api_client.delete(f"/api/download/{test_id}/cancel") - - time.sleep(0.5) + cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") + assert cancel_resp.status_code == 200 + assert cancel_resp.json() == {"status": "cancelled", "book_id": test_id} # Check it's not in the queue - queue_resp = protected_api_client.get("/api/queue/order") - if queue_resp.status_code == 200: - queue_order = queue_resp.json() - assert test_id not in queue_order + _wait_for_queue_absence( + protected_api_client, + test_id, + context="cancel verification", + ) @pytest.mark.e2e @@ -376,10 +525,13 @@ class TestQueuePriority: }, ) - if queue_resp.status_code != 200: - pytest.skip("Could not queue download") + assert_queued_download_response(queue_resp) - time.sleep(0.5) + _require_queue_entry( + protected_api_client, + test_id, + context="priority update precondition", + ) # Update priority priority_resp = protected_api_client.put( @@ -387,5 +539,11 @@ class TestQueuePriority: json={"priority": 10}, ) - # Should succeed or return 404 if already processed - assert priority_resp.status_code in [200, 404] + assert priority_resp.status_code == 200 + assert priority_resp.json() == {"status": "updated", "book_id": test_id, "priority": 10} + + queue_resp = protected_api_client.get("/api/queue/order") + queue_order = assert_queue_order_response(queue_resp) + matching_entries = [entry for entry in queue_order if entry.get("id") == test_id] + assert len(matching_entries) == 1 + assert matching_entries[0]["priority"] == 10 diff --git a/tests/e2e/test_prowlarr_flow.py b/tests/e2e/test_prowlarr_flow.py index 7f36f5a..a2987be 100644 --- a/tests/e2e/test_prowlarr_flow.py +++ b/tests/e2e/test_prowlarr_flow.py @@ -7,49 +7,147 @@ Requires Prowlarr and a download client (qBittorrent, Transmission, etc.) to be Run with: uv run pytest tests/e2e/test_prowlarr_flow.py -v -m e2e """ -import time - import pytest -from .conftest import APIClient, DownloadTracker +from .conftest import ( + DOWNLOAD_TIMEOUT, + SUCCESS_DOWNLOAD_STATES, + APIClient, + DownloadTracker, + assert_queued_download_response, +) + + +def _assert_terminal_download_result( + result: dict[str, object], + *, + source_id: str, + expected_title: str, + expected_source: str, +) -> None: + """Assert that a finished Prowlarr download produced a structured payload.""" + state = result["state"] + entry = result["data"] + assert isinstance(entry, dict) + if state == "error": + error_message = str( + entry.get("status_message") or entry.get("last_error_message") or "" + ).strip() + normalized_error = error_message.lower() + if any( + marker in normalized_error + for marker in ( + "failed to connect", + "connection error", + "connection refused", + "name or service not known", + "max retries exceeded", + "timed out", + ) + ): + pytest.skip(f"{expected_source} dependency unavailable: {error_message}") + pytest.fail( + f"{expected_source} download failed" + f"{f': {error_message}' if error_message else f': {entry!r}'}" + ) + + assert state in SUCCESS_DOWNLOAD_STATES, ( + f"{expected_source} ended in unexpected state {state!r}: {entry!r}" + ) + assert entry.get("id") == source_id + assert entry.get("title") == expected_title + assert entry.get("source") == expected_source + status = entry.get("status") + assert status is None or status in SUCCESS_DOWNLOAD_STATES | {"queued"} + + +def _is_duplicate_queue_error(response) -> bool: + """Whether the API refused to queue a release because it already exists.""" + if response.status_code != 500: + return False + try: + payload = response.json() + except ValueError: + return False + return payload == {"error": "Release is already in the download queue"} + + +def _require_json_object( + response, *, context: str, skip_statuses: set[int] | frozenset[int] = frozenset({503}) +) -> dict[str, object]: + if response.status_code in skip_statuses: + pytest.skip(f"{context} unavailable: {response.status_code}") + assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" + payload = response.json() + assert isinstance(payload, dict), f"{context} did not return a JSON object: {payload!r}" + return payload + + +def _extract_result_list(payload: object, *, context: str) -> list[dict[str, object]]: + if isinstance(payload, dict): + if "books" in payload: + results = payload["books"] + elif "releases" in payload: + results = payload["releases"] + else: + results = payload.get("results", payload) + else: + results = payload + + if isinstance(results, dict): + list_values = [value for value in results.values() if isinstance(value, list)] + if not list_values: + pytest.fail(f"{context} returned an unexpected result structure: {payload!r}") + if not any(list_values): + pytest.skip(f"{context} returned no results") + results = next(value for value in list_values if value) + + if not isinstance(results, list): + pytest.fail(f"{context} did not return a result list: {payload!r}") + if not results: + pytest.skip(f"{context} returned no results") + return results def _is_prowlarr_configured(api_client: APIClient) -> bool: """Check if Prowlarr is configured and available.""" resp = api_client.get("/api/release-sources") - if resp.status_code != 200: - return False + if resp.status_code == 503: + pytest.skip("Release sources unavailable") + assert resp.status_code == 200, f"Release sources failed: {resp.status_code} {resp.text}" sources = resp.json() - for source in sources: - if source.get("name") == "prowlarr": - return True - return False - - -def _get_prowlarr_settings(api_client: APIClient) -> dict | None: - """Get Prowlarr settings if available.""" - resp = api_client.get("/api/settings/prowlarr") - if resp.status_code == 200: - return resp.json() - return None + if not isinstance(sources, list): + pytest.fail(f"Release sources returned an unexpected payload: {sources!r}") + if not all(isinstance(source, dict) for source in sources): + pytest.fail(f"Release sources returned a malformed payload: {sources!r}") + return any(source.get("name") == "prowlarr" for source in sources) def _get_first_provider_name(api_client: APIClient) -> str | None: """Get the first available provider name.""" providers_resp = api_client.get("/api/metadata/providers") - if providers_resp.status_code != 200: - return None + if providers_resp.status_code == 503: + pytest.skip("Metadata providers unavailable") + assert providers_resp.status_code == 200, ( + f"Metadata providers failed: {providers_resp.status_code} {providers_resp.text}" + ) providers_data = providers_resp.json() - if not providers_data: - return None - - # Handle both dict and list formats - if isinstance(providers_data, dict): - return list(providers_data.keys())[0] if providers_data else None - else: - return providers_data[0].get("name") if providers_data else None + if not isinstance(providers_data, (dict, list)): + pytest.fail(f"Metadata providers returned an unexpected payload: {providers_data!r}") + providers = ( + providers_data.get("providers", []) if isinstance(providers_data, dict) else providers_data + ) + for provider in providers: + if ( + isinstance(provider, dict) + and provider.get("name") + and provider.get("enabled") is True + and provider.get("available") is True + ): + return provider["name"] + return None @pytest.mark.e2e @@ -62,6 +160,10 @@ class TestProwlarrConfiguration: assert resp.status_code == 200 sources = resp.json() + assert isinstance(sources, list), f"Unexpected release sources payload: {sources!r}" + assert all(isinstance(source, dict) for source in sources), ( + f"Unexpected release sources payload: {sources!r}" + ) source_names = [s.get("name") for s in sources] assert "prowlarr" in source_names @@ -83,9 +185,11 @@ class TestProwlarrConfiguration: all_tab_names = [] for group in data.get("groups", []): if isinstance(group, dict): - for tab in group.get("tabs", []): - if isinstance(tab, dict): - all_tab_names.append(tab.get("name") or tab.get("id", "")) + all_tab_names.extend( + tab.get("name") or tab.get("id", "") + for tab in group.get("tabs", []) + if isinstance(tab, dict) + ) tab_names = all_tab_names else: tab_names = list(data.keys()) @@ -124,30 +228,12 @@ class TestProwlarrSearch: timeout=30, ) - if search_resp.status_code != 200: - pytest.skip("Metadata search unavailable") - - search_data = search_resp.json() - results = search_data.get("results", search_data) - - # Handle dict format where results might be nested - if isinstance(results, dict) and "results" not in results: - # Results might be the actual result list under a different key - for key, value in results.items(): - if isinstance(value, list) and value: - results = value - break - - if not results or (isinstance(results, dict) and not results): - pytest.skip("No metadata results") - - # Get first result - if isinstance(results, list): - book = results[0] - else: - pytest.skip("Unexpected results format") + search_data = _require_json_object(search_resp, context="metadata search") + results = _extract_result_list(search_data, context="metadata search") + book = results[0] book_id = book.get("id") or book.get("provider_id") + assert book_id, "Metadata search result missing ID" # Now search releases specifically from Prowlarr releases_resp = protected_api_client.get( @@ -162,14 +248,13 @@ class TestProwlarrSearch: timeout=60, ) - # Prowlarr may not be reachable - if releases_resp.status_code == 503: - pytest.skip("Prowlarr not reachable") - - if releases_resp.status_code == 200: - data = releases_resp.json() - assert "releases" in data - # Releases may be empty if Prowlarr has no indexers configured + data = _require_json_object(releases_resp, context="Prowlarr release search") + assert data.get("sources_searched") == ["prowlarr"] + releases = data.get("releases") + assert isinstance(releases, list), f"Unexpected Prowlarr release payload: {data!r}" + if not releases: + pytest.skip("No Prowlarr releases found") + assert "book" in data @pytest.mark.e2e @@ -200,22 +285,30 @@ class TestProwlarrClientSettings: pytest.skip("Settings not available") current = get_resp.json() + assert isinstance(current, dict), f"Unexpected prowlarr_clients payload: {current!r}" + fields = current.get("fields") + assert isinstance(fields, list) and fields, ( + f"Prowlarr client settings payload missing fields: {current!r}" + ) - # Try to save the same settings back (no-op save) - if isinstance(current, dict) and "fields" in current: - # Extract just the values - values = {} - for field in current.get("fields", []): - key = field.get("key") or field.get("name") - if key: - values[key] = field.get("value", "") + values = {} + for field in fields: + if not isinstance(field, dict): + continue + key = field.get("key") or field.get("name") + if key: + values[key] = field.get("value", "") - put_resp = protected_api_client.put( - "/api/settings/prowlarr_clients", - json=values, - ) - # Should succeed (200) or be a no-op - assert put_resp.status_code in [200, 204, 400] + assert values, f"No editable values found in prowlarr_clients payload: {current!r}" + + put_resp = protected_api_client.put( + "/api/settings/prowlarr_clients", + json=values, + ) + assert put_resp.status_code in [200, 204] + if put_resp.status_code == 200: + payload = put_resp.json() + assert isinstance(payload, dict) @pytest.mark.e2e @@ -241,24 +334,11 @@ class TestProwlarrDownload: timeout=30, ) - if search_resp.status_code != 200: - pytest.skip("Metadata search failed") - - search_data = search_resp.json() - results = search_data.get("results", search_data) - - # Handle different result formats - if isinstance(results, dict): - for key, value in results.items(): - if isinstance(value, list) and value: - results = value - break - - if not results or not isinstance(results, list): - pytest.skip("No results") - + search_data = _require_json_object(search_resp, context="metadata search") + results = _extract_result_list(search_data, context="metadata search") book = results[0] book_id = book.get("id") or book.get("provider_id") + assert book_id, "Metadata search result missing ID" # Search Prowlarr releases releases_resp = protected_api_client.get( @@ -272,10 +352,9 @@ class TestProwlarrDownload: timeout=60, ) - if releases_resp.status_code != 200: - pytest.skip(f"Releases search failed: {releases_resp.status_code}") - - releases = releases_resp.json().get("releases", []) + releases_data = _require_json_object(releases_resp, context="Prowlarr release search") + releases = releases_data.get("releases") + assert isinstance(releases, list), f"Unexpected Prowlarr release payload: {releases_data!r}" if not releases: pytest.skip("No Prowlarr releases found") @@ -297,24 +376,29 @@ class TestProwlarrDownload: }, ) - # May fail if no download client configured - if queue_resp.status_code == 200: - data = queue_resp.json() - assert data.get("status") == "queued" - - # Wait briefly and check status - time.sleep(3) + if not _is_duplicate_queue_error(queue_resp): + assert_queued_download_response(queue_resp) + result = download_tracker.wait_for_status( + source_id, + target_states=["complete", "done", "available"], + timeout=DOWNLOAD_TIMEOUT, + ) + if result is None: status_resp = protected_api_client.get("/api/status") if status_resp.status_code == 200: status_data = status_resp.json() - # Should be in one of the status categories - found = False - for category in status_data.values(): - if isinstance(category, dict) and source_id in category: - found = True - break - # It's ok if not found (may have already processed/errored) + error_info = status_data.get("error", {}).get(source_id) + if error_info: + pytest.fail(f"Prowlarr download failed: {error_info}") + pytest.fail("Prowlarr download timed out") + + _assert_terminal_download_result( + result, + source_id=source_id, + expected_title=release.get("title", book.get("title", "Test")), + expected_source="prowlarr", + ) @pytest.mark.e2e @@ -328,11 +412,13 @@ class TestProwlarrClientConnection: "/api/settings/prowlarr_clients/action/test_torrent_connection" ) - # May succeed, fail, or not exist depending on configuration - # We just verify it returns a response - assert resp.status_code in [200, 400, 404, 500] + # May succeed, fail, or not exist depending on configuration. + # A 500 is a real server error and should fail the test. + assert resp.status_code in [200, 400, 404] if resp.status_code == 200: data = resp.json() - # Should have success/message structure - assert "success" in data or "message" in data or "result" in data + assert isinstance(data, dict) + assert "success" in data or "message" in data + if "message" in data: + assert isinstance(data["message"], str) diff --git a/tests/e2e/test_proxy_auth_middleware.py b/tests/e2e/test_proxy_auth_middleware.py index b74ee38..3db8607 100644 --- a/tests/e2e/test_proxy_auth_middleware.py +++ b/tests/e2e/test_proxy_auth_middleware.py @@ -9,6 +9,8 @@ from uuid import uuid4 import pytest +pytestmark = pytest.mark.e2e + def _as_response(result: Any): if isinstance(result, tuple) and len(result) == 2: @@ -36,52 +38,79 @@ def main_module(): class TestProxyAuthMiddleware: def test_skips_for_non_proxy_mode(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/releases"): - result = main_module.proxy_auth_middleware() - assert result is None - assert "user_id" not in main_module.session + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/releases"), + ): + result = main_module.proxy_auth_middleware() + assert result is None + assert "user_id" not in main_module.session def test_skips_health_endpoint(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with main_module.app.test_request_context("/api/health"): - result = main_module.proxy_auth_middleware() - assert result is None + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + main_module.app.test_request_context("/api/health"), + ): + result = main_module.proxy_auth_middleware() + assert result is None def test_allows_auth_check_without_header(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context("/api/auth/check"): - result = main_module.proxy_auth_middleware() - assert result is None - assert "user_id" not in main_module.session + ), + main_module.app.test_request_context("/api/auth/check"), + ): + result = main_module.proxy_auth_middleware() + assert result is None + assert "user_id" not in main_module.session def test_sets_session_from_header(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={"X-Auth-User": "proxyuser"}, - ): - result = main_module.proxy_auth_middleware() - assert result is None - assert main_module.session.get("user_id") == "proxyuser" - assert main_module.session.get("is_admin") is True - db_user_id = main_module.session.get("db_user_id") - assert db_user_id is not None - db_user = main_module.user_db.get_user(user_id=db_user_id) - assert db_user is not None - assert db_user["username"] == "proxyuser" - assert db_user["auth_source"] == "proxy" - assert main_module.session.permanent is False + ), + main_module.app.test_request_context( + "/api/releases", + headers={"X-Auth-User": "proxyuser"}, + ), + ): + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("user_id") == "proxyuser" + assert main_module.session.get("is_admin") is True + db_user_id = main_module.session.get("db_user_id") + assert db_user_id is not None + db_user = main_module.user_db.get_user(user_id=db_user_id) + assert db_user is not None + assert db_user["username"] == "proxyuser" + assert db_user["auth_source"] == "proxy" + assert main_module.session.permanent is False + + def test_reads_remote_user_wsgi_fallback(self, main_module): + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( + main_module.app_config, + "get", + side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "Remote-User"}), + ), + main_module.app.test_request_context( + "/api/releases", + environ_base={"REMOTE_USER": "proxyremote"}, + ), + ): + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("user_id") == "proxyremote" + assert main_module.session.get("is_admin") is True + assert main_module.session.permanent is False def test_proxy_takes_over_existing_local_username(self, main_module): existing = main_module.user_db.create_user( @@ -90,76 +119,82 @@ class TestProxyAuthMiddleware: auth_source="builtin", ) - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={"X-Auth-User": "proxy_takeover_local"}, - ): - result = main_module.proxy_auth_middleware() - assert result is None + ), + main_module.app.test_request_context( + "/api/releases", + headers={"X-Auth-User": "proxy_takeover_local"}, + ), + ): + result = main_module.proxy_auth_middleware() + assert result is None - db_user_id = main_module.session.get("db_user_id") - db_user = main_module.user_db.get_user(user_id=db_user_id) - assert db_user is not None - assert db_user["id"] == existing["id"] - assert db_user["username"] == "proxy_takeover_local" - assert db_user["auth_source"] == "proxy" + db_user_id = main_module.session.get("db_user_id") + db_user = main_module.user_db.get_user(user_id=db_user_id) + assert db_user is not None + assert db_user["id"] == existing["id"] + assert db_user["username"] == "proxy_takeover_local" + assert db_user["auth_source"] == "proxy" def test_reprovisions_when_proxy_identity_changes(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={"X-Auth-User": "proxyuser2"}, - ): - main_module.session["user_id"] = "old-user" - main_module.session["db_user_id"] = 999999 + ), + main_module.app.test_request_context( + "/api/releases", + headers={"X-Auth-User": "proxyuser2"}, + ), + ): + main_module.session["user_id"] = "old-user" + main_module.session["db_user_id"] = 999999 - result = main_module.proxy_auth_middleware() - assert result is None - assert main_module.session.get("user_id") == "proxyuser2" - db_user_id = main_module.session.get("db_user_id") - db_user = main_module.user_db.get_user(user_id=db_user_id) - assert db_user["username"] == "proxyuser2" + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("user_id") == "proxyuser2" + db_user_id = main_module.session.get("db_user_id") + db_user = main_module.user_db.get_user(user_id=db_user_id) + assert db_user["username"] == "proxyuser2" def test_reprovisions_when_session_db_user_is_stale(self, main_module): stale_user_id = 99999999 username = f"proxy_stale_{uuid4().hex[:8]}" assert main_module.user_db.get_user(user_id=stale_user_id) is None - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={"X-Auth-User": username}, - ): - main_module.session["user_id"] = username - main_module.session["db_user_id"] = stale_user_id + ), + main_module.app.test_request_context( + "/api/releases", + headers={"X-Auth-User": username}, + ), + ): + main_module.session["user_id"] = username + main_module.session["db_user_id"] = stale_user_id - result = main_module.proxy_auth_middleware() - assert result is None - assert main_module.session.get("user_id") == username + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("user_id") == username - db_user_id = main_module.session.get("db_user_id") - assert db_user_id is not None - assert db_user_id != stale_user_id + db_user_id = main_module.session.get("db_user_id") + assert db_user_id is not None + assert db_user_id != stale_user_id - db_user = main_module.user_db.get_user(user_id=db_user_id) - assert db_user is not None - assert db_user["username"] == username + db_user = main_module.user_db.get_user(user_id=db_user_id) + assert db_user is not None + assert db_user["username"] == username def test_reprovisions_when_session_db_user_points_to_other_username(self, main_module): username = f"proxy_target_{uuid4().hex[:8]}" @@ -169,66 +204,74 @@ class TestProxyAuthMiddleware: auth_source="proxy", ) - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={"X-Auth-User": username}, - ): - main_module.session["user_id"] = username - main_module.session["db_user_id"] = other_user["id"] + ), + main_module.app.test_request_context( + "/api/releases", + headers={"X-Auth-User": username}, + ), + ): + main_module.session["user_id"] = username + main_module.session["db_user_id"] = other_user["id"] - result = main_module.proxy_auth_middleware() - assert result is None - assert main_module.session.get("user_id") == username + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("user_id") == username - db_user_id = main_module.session.get("db_user_id") - assert db_user_id is not None - assert db_user_id != other_user["id"] + db_user_id = main_module.session.get("db_user_id") + assert db_user_id is not None + assert db_user_id != other_user["id"] - db_user = main_module.user_db.get_user(user_id=db_user_id) - assert db_user is not None - assert db_user["username"] == username + db_user = main_module.user_db.get_user(user_id=db_user_id) + assert db_user is not None + assert db_user["username"] == username def test_returns_401_when_header_missing_on_protected_path(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}), - ): - with main_module.app.test_request_context("/api/releases"): - resp = _as_response(main_module.proxy_auth_middleware()) - data = resp.get_json() + ), + main_module.app.test_request_context("/api/releases"), + ): + resp = _as_response(main_module.proxy_auth_middleware()) + data = resp.get_json() assert resp.status_code == 401 - assert "Authentication required" in (data.get("error") or "") + assert data == {"error": "Authentication required. Proxy header not set."} def test_admin_group_membership(self, main_module): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with patch.object( + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + patch.object( main_module.app_config, "get", - side_effect=_config_getter({ - "PROXY_AUTH_USER_HEADER": "X-Auth-User", - "PROXY_AUTH_ADMIN_GROUP_HEADER": "X-Auth-Groups", - "PROXY_AUTH_ADMIN_GROUP_NAME": "admins", - }), - ): - with main_module.app.test_request_context( - "/api/releases", - headers={ - "X-Auth-User": "adminuser", - "X-Auth-Groups": "users,admins,devs", - }, - ): - result = main_module.proxy_auth_middleware() - assert result is None - assert main_module.session.get("is_admin") is True + side_effect=_config_getter( + { + "PROXY_AUTH_USER_HEADER": "X-Auth-User", + "PROXY_AUTH_ADMIN_GROUP_HEADER": "X-Auth-Groups", + "PROXY_AUTH_ADMIN_GROUP_NAME": "admins", + } + ), + ), + main_module.app.test_request_context( + "/api/releases", + headers={ + "X-Auth-User": "adminuser", + "X-Auth-Groups": "users,admins,devs", + }, + ), + ): + result = main_module.proxy_auth_middleware() + assert result is None + assert main_module.session.get("is_admin") is True class TestLoginRequiredDecorator: @@ -240,84 +283,100 @@ class TestLoginRequiredDecorator: return _view def test_allows_no_auth(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="none"): - with main_module.app.test_request_context("/api/releases"): - decorated = main_module.login_required(view) - resp = decorated() + with ( + patch.object(main_module, "get_auth_mode", return_value="none"), + main_module.app.test_request_context("/api/releases"), + ): + decorated = main_module.login_required(view) + resp = decorated() assert resp[0]["success"] is True def test_blocks_when_not_authenticated(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/releases"): - decorated = main_module.login_required(view) - resp = _as_response(decorated()) + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/releases"), + ): + decorated = main_module.login_required(view) + resp = _as_response(decorated()) assert resp.status_code == 401 def test_allows_authenticated(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/releases"): - main_module.session["user_id"] = "user" - decorated = main_module.login_required(view) - resp = decorated() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/releases"), + ): + main_module.session["user_id"] = "user" + decorated = main_module.login_required(view) + resp = decorated() assert resp[0]["success"] is True def test_settings_access_requires_admin_even_when_legacy_toggle_off(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/settings/general"): - main_module.session["user_id"] = "user" - main_module.session["is_admin"] = False - decorated = main_module.login_required(view) - resp = _as_response(decorated()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/settings/general"), + ): + main_module.session["user_id"] = "user" + main_module.session["is_admin"] = False + decorated = main_module.login_required(view) + resp = _as_response(decorated()) + data = resp.get_json() assert resp.status_code == 403 assert "Admin access required" in (data.get("error") or "") def test_security_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/settings/security"): - main_module.session["user_id"] = "user" - main_module.session["is_admin"] = False - decorated = main_module.login_required(view) - resp = _as_response(decorated()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/settings/security"), + ): + main_module.session["user_id"] = "user" + main_module.session["is_admin"] = False + decorated = main_module.login_required(view) + resp = _as_response(decorated()) + data = resp.get_json() assert resp.status_code == 403 assert "Admin access required" in (data.get("error") or "") def test_users_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="builtin"): - with main_module.app.test_request_context("/api/settings/users"): - main_module.session["user_id"] = "user" - main_module.session["is_admin"] = False - decorated = main_module.login_required(view) - resp = _as_response(decorated()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="builtin"), + main_module.app.test_request_context("/api/settings/users"), + ): + main_module.session["user_id"] = "user" + main_module.session["is_admin"] = False + decorated = main_module.login_required(view) + resp = _as_response(decorated()) + data = resp.get_json() assert resp.status_code == 403 assert "Admin access required" in (data.get("error") or "") def test_proxy_admin_restriction_blocks_non_admin(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="proxy"): - with main_module.app.test_request_context("/api/settings/general"): - main_module.session["user_id"] = "user" - main_module.session["is_admin"] = False - decorated = main_module.login_required(view) - resp = _as_response(decorated()) - data = resp.get_json() + with ( + patch.object(main_module, "get_auth_mode", return_value="proxy"), + main_module.app.test_request_context("/api/settings/general"), + ): + main_module.session["user_id"] = "user" + main_module.session["is_admin"] = False + decorated = main_module.login_required(view) + resp = _as_response(decorated()) + data = resp.get_json() assert resp.status_code == 403 assert "Admin access required" in (data.get("error") or "") def test_cwa_admin_restriction_blocks_non_admin(self, main_module, view): - with patch.object(main_module, "get_auth_mode", return_value="cwa"): - with main_module.app.test_request_context("/api/settings/general"): - main_module.session["user_id"] = "user" - main_module.session["is_admin"] = False - decorated = main_module.login_required(view) - resp = _as_response(decorated()) + with ( + patch.object(main_module, "get_auth_mode", return_value="cwa"), + main_module.app.test_request_context("/api/settings/general"), + ): + main_module.session["user_id"] = "user" + main_module.session["is_admin"] = False + decorated = main_module.login_required(view) + resp = _as_response(decorated()) assert resp.status_code == 403 diff --git a/tests/irc/test_cache.py b/tests/irc/test_cache.py index 7e0b92f..ab901af 100644 --- a/tests/irc/test_cache.py +++ b/tests/irc/test_cache.py @@ -12,10 +12,16 @@ def test_cache_results_isolated_by_content_type(monkeypatch): audiobook_release = Release(source="irc", source_id="audio", title="Shared Title", format="zip") cache.cache_results("hardcover", "123", "Shared Title", [ebook_release], content_type="ebook") - cache.cache_results("hardcover", "123", "Shared Title", [audiobook_release], content_type="audiobook") + cache.cache_results( + "hardcover", "123", "Shared Title", [audiobook_release], content_type="audiobook" + ) - ebook_cached = cache.get_cached_results("hardcover", "123", content_type="ebook", ttl_seconds=60) - audiobook_cached = cache.get_cached_results("hardcover", "123", content_type="audiobook", ttl_seconds=60) + ebook_cached = cache.get_cached_results( + "hardcover", "123", content_type="ebook", ttl_seconds=60 + ) + audiobook_cached = cache.get_cached_results( + "hardcover", "123", content_type="audiobook", ttl_seconds=60 + ) assert [release.source_id for release in ebook_cached["releases"]] == ["ebook"] assert [release.source_id for release in audiobook_cached["releases"]] == ["audio"] diff --git a/tests/irc/test_source.py b/tests/irc/test_source.py index dfade7a..2184256 100644 --- a/tests/irc/test_source.py +++ b/tests/irc/test_source.py @@ -1,3 +1,7 @@ +from types import SimpleNamespace + +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources import Release from shelfmark.release_sources.irc.parser import SearchResult from shelfmark.release_sources.irc.source import IRCReleaseSource @@ -29,3 +33,107 @@ def test_convert_to_releases_marks_audiobook_results_and_sorts_audio_before_arch assert [release.format for release in releases] == ["m4b", "zip"] assert all(release.content_type == "audiobook" for release in releases) + + +def test_search_uses_cached_results_without_opening_a_connection(monkeypatch): + import shelfmark.release_sources.irc.source as irc_source + + source = IRCReleaseSource() + cached_release = Release( + source="irc", + source_id="cached-line", + title="Cached Result", + ) + + monkeypatch.setattr(source, "is_available", lambda: True) + monkeypatch.setattr( + "shelfmark.release_sources.irc.cache.get_cached_results", + lambda provider, provider_id, *, content_type: { + "releases": [cached_release], + "online_servers": ["AudioBot"], + }, + ) + monkeypatch.setattr( + "shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("cache hit should skip IRC connection") + ), + ) + monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None) + + book = BookMetadata(provider="hardcover", provider_id="123", title="Cached Book") + plan = SimpleNamespace(primary_query="Cached Book") + + releases = source.search(book, plan) + + assert releases == [cached_release] + assert source._online_servers == {"AudioBot"} + + +def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeypatch): + import shelfmark.release_sources.irc.source as irc_source + + source = IRCReleaseSource() + cache_calls: list[dict[str, object]] = [] + released_clients: list[object] = [] + + class FakeClient: + online_servers = {"AudioBot"} + + def send_message(self, channel: str, message: str) -> None: + self.channel = channel + self.message = message + + def wait_for_dcc(self, *, timeout: float, result_type: bool) -> None: + return None + + client = FakeClient() + + monkeypatch.setattr(source, "is_available", lambda: True) + monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None) + monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "shelfmark.release_sources.irc.cache.get_cached_results", + lambda provider, provider_id, *, content_type: None, + ) + monkeypatch.setattr( + "shelfmark.release_sources.irc.cache.cache_results", + lambda provider, provider_id, title, releases, *, content_type, online_servers: ( + cache_calls.append( + { + "provider": provider, + "provider_id": provider_id, + "title": title, + "releases": releases, + "content_type": content_type, + "online_servers": online_servers, + } + ) + ), + ) + monkeypatch.setattr( + "shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection", + lambda **_kwargs: client, + ) + monkeypatch.setattr( + "shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection", + lambda released_client: released_clients.append(released_client), + ) + + book = BookMetadata(provider="hardcover", provider_id="abc", title="Missing Result") + plan = SimpleNamespace(primary_query="Missing Result") + + releases = source.search(book, plan, content_type="audiobook") + + assert releases == [] + assert released_clients == [client] + assert cache_calls == [ + { + "provider": "hardcover", + "provider_id": "abc", + "title": "Missing Result", + "releases": [], + "content_type": "audiobook", + "online_servers": ["AudioBot"], + } + ] diff --git a/tests/metadata/test_hardcover_field_options.py b/tests/metadata/test_hardcover_field_options.py index 2c13c04..59e40c3 100644 --- a/tests/metadata/test_hardcover_field_options.py +++ b/tests/metadata/test_hardcover_field_options.py @@ -19,18 +19,21 @@ class TestHardcoverFieldOptions: monkeypatch.setattr( provider, "_execute_query", - lambda query, variables: captured.update({"query": query, "variables": variables}) or { - "search": { - "results": { - "hits": [ - {"document": {"name": "Brandon Sanderson"}}, - {"document": {"name": "Brandon Sanderson"}}, - {"document": {"name": "Brian Sanderson"}}, - ], - "found": 3, + lambda query, variables: ( + captured.update({"query": query, "variables": variables}) + or { + "search": { + "results": { + "hits": [ + {"document": {"name": "Brandon Sanderson"}}, + {"document": {"name": "Brandon Sanderson"}}, + {"document": {"name": "Brian Sanderson"}}, + ], + "found": 3, + } } } - }, + ), ) options = provider.get_search_field_options("author", query="sand") @@ -64,50 +67,53 @@ class TestHardcoverFieldOptions: monkeypatch.setattr( provider, "_execute_query", - lambda query, variables: captured.update({"query": query, "variables": variables}) or { - "search": { - "results": { - "hits": [ - { - "document": { - "title": "Mistborn: The Final Empire", - "compilation": False, - "release_year": 2006, - } - }, - { - "document": { - "title": "Mistborn Trilogy", - "compilation": True, - "release_year": 2001, - } - }, - { - "document": { - "title": "Ghostbloods 1", - "compilation": False, - "release_year": 2028, - } - }, - { - "document": { - "title": "Mistborn: The Final Empire", - "compilation": False, - "release_year": 2006, - } - }, - { - "document": { - "title": "Mistborn: Secret History", - "compilation": False, - "release_year": 2016, - } - }, - ], - "found": 5, + lambda query, variables: ( + captured.update({"query": query, "variables": variables}) + or { + "search": { + "results": { + "hits": [ + { + "document": { + "title": "Mistborn: The Final Empire", + "compilation": False, + "release_year": 2006, + } + }, + { + "document": { + "title": "Mistborn Trilogy", + "compilation": True, + "release_year": 2001, + } + }, + { + "document": { + "title": "Ghostbloods 1", + "compilation": False, + "release_year": 2028, + } + }, + { + "document": { + "title": "Mistborn: The Final Empire", + "compilation": False, + "release_year": 2006, + } + }, + { + "document": { + "title": "Mistborn: Secret History", + "compilation": False, + "release_year": 2016, + } + }, + ], + "found": 5, + } } } - }, + ), ) options = provider.get_search_field_options("title", query="mistborn") diff --git a/tests/metadata/test_hardcover_lists.py b/tests/metadata/test_hardcover_lists.py index ebbcade..8a51d2c 100644 --- a/tests/metadata/test_hardcover_lists.py +++ b/tests/metadata/test_hardcover_lists.py @@ -257,7 +257,9 @@ class TestHardcoverLists: ], ) monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123") - monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub) + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub + ) def fake_execute(query: str, variables, raise_on_error: bool = False): if "query GetBookTargetMembership" in query: @@ -315,7 +317,9 @@ class TestHardcoverLists: ], ) monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123") - monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub) + monkeypatch.setattr( + "shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub + ) def fake_execute(query: str, variables, raise_on_error: bool = False): if "query GetBookTargetMembership" in query: diff --git a/tests/metadata/test_hardcover_search_title.py b/tests/metadata/test_hardcover_search_title.py index 5b433b6..4e70a02 100644 --- a/tests/metadata/test_hardcover_search_title.py +++ b/tests/metadata/test_hardcover_search_title.py @@ -1,5 +1,3 @@ -import pytest - from shelfmark.metadata_providers.hardcover import _compute_search_title diff --git a/tests/metadata/test_hardcover_series_search.py b/tests/metadata/test_hardcover_series_search.py index 6ba9bfa..defcac5 100644 --- a/tests/metadata/test_hardcover_series_search.py +++ b/tests/metadata/test_hardcover_series_search.py @@ -245,69 +245,76 @@ class TestHardcoverSeriesSearch: monkeypatch.setattr( provider, "_execute_query", - lambda query, variables: captured.update({"query": query, "variables": variables}) or { - "series": [ - { - "id": 42, - "name": "Mistborn", - "primary_books_count": 3, - "book_series": [ - { - "position": 1, - "book": { - "id": 100, - "title": "The Final Empire", - "subtitle": None, - "slug": "the-final-empire", - "release_date": "2006-07-17", - "headline": None, - "description": "A heist.", - "rating": 4.5, - "ratings_count": 120, - "users_count": 250, - "cached_image": {"url": "https://example.com/final-empire.jpg"}, - "cached_contributors": [{"name": "Brandon Sanderson"}], - "contributions": [], - "featured_book_series": { - "position": 1, - "series": { - "id": 42, - "name": "Mistborn", - "primary_books_count": 3, + lambda query, variables: ( + captured.update({"query": query, "variables": variables}) + or { + "series": [ + { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + "book_series": [ + { + "position": 1, + "book": { + "id": 100, + "title": "The Final Empire", + "subtitle": None, + "slug": "the-final-empire", + "release_date": "2006-07-17", + "headline": None, + "description": "A heist.", + "rating": 4.5, + "ratings_count": 120, + "users_count": 250, + "cached_image": { + "url": "https://example.com/final-empire.jpg" + }, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 1, + "series": { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + }, }, }, }, - }, - { - "position": 2, - "book": { - "id": 101, - "title": "The Well of Ascension", - "subtitle": None, - "slug": "the-well-of-ascension", - "release_date": "2007-08-21", - "headline": None, - "description": "The sequel.", - "rating": 4.4, - "ratings_count": 110, - "users_count": 220, - "cached_image": {"url": "https://example.com/well-of-ascension.jpg"}, - "cached_contributors": [{"name": "Brandon Sanderson"}], - "contributions": [], - "featured_book_series": { - "position": 2, - "series": { - "id": 42, - "name": "Mistborn", - "primary_books_count": 3, + { + "position": 2, + "book": { + "id": 101, + "title": "The Well of Ascension", + "subtitle": None, + "slug": "the-well-of-ascension", + "release_date": "2007-08-21", + "headline": None, + "description": "The sequel.", + "rating": 4.4, + "ratings_count": 110, + "users_count": 220, + "cached_image": { + "url": "https://example.com/well-of-ascension.jpg" + }, + "cached_contributors": [{"name": "Brandon Sanderson"}], + "contributions": [], + "featured_book_series": { + "position": 2, + "series": { + "id": 42, + "name": "Mistborn", + "primary_books_count": 3, + }, }, }, }, - }, - ], - } - ] - }, + ], + } + ] + } + ), ) result = provider._fetch_series_books_by_id( @@ -332,7 +339,9 @@ class TestHardcoverSeriesSearch: assert result.books[0].series_position == 1 assert result.books[0].series_count == 2 - def test_fetch_series_books_by_id_skips_split_part_entries_for_standard_series(self, monkeypatch): + def test_fetch_series_books_by_id_skips_split_part_entries_for_standard_series( + self, monkeypatch + ): provider = HardcoverProvider(api_key="test-token") monkeypatch.setattr( @@ -721,7 +730,9 @@ class TestHardcoverSeriesSearch: "users_count": 40, "editions_count": 1, "compilation": False, - "cached_image": {"url": "https://example.com/ghostbloods-2.jpg"}, + "cached_image": { + "url": "https://example.com/ghostbloods-2.jpg" + }, "cached_contributors": [{"name": "Brandon Sanderson"}], "contributions": [], "featured_book_series": { diff --git a/tests/prowlarr/test_bencode.py b/tests/prowlarr/test_bencode.py index 93b571b..fe0be2c 100644 --- a/tests/prowlarr/test_bencode.py +++ b/tests/prowlarr/test_bencode.py @@ -2,11 +2,13 @@ Tests for bencode encoding/decoding in the torrent utilities. """ -import pytest - +from shelfmark.download.clients.torrent_utils import ( + bencode_decode as _bencode_decode, +) from shelfmark.download.clients.torrent_utils import ( bencode_encode as _bencode_encode, - bencode_decode as _bencode_decode, +) +from shelfmark.download.clients.torrent_utils import ( extract_info_hash_from_torrent as _extract_info_hash_from_torrent, ) @@ -22,12 +24,12 @@ class TestBencodeDecode: def test_decode_negative_integer(self): """Test decoding negative integers.""" - result, remaining = _bencode_decode(b"i-42e") + result, _remaining = _bencode_decode(b"i-42e") assert result == -42 def test_decode_zero(self): """Test decoding zero.""" - result, remaining = _bencode_decode(b"i0e") + result, _remaining = _bencode_decode(b"i0e") assert result == 0 def test_decode_string(self): @@ -38,7 +40,7 @@ class TestBencodeDecode: def test_decode_empty_string(self): """Test decoding empty string.""" - result, remaining = _bencode_decode(b"0:") + result, _remaining = _bencode_decode(b"0:") assert result == b"" def test_decode_list(self): @@ -49,12 +51,12 @@ class TestBencodeDecode: def test_decode_empty_list(self): """Test decoding empty list.""" - result, remaining = _bencode_decode(b"le") + result, _remaining = _bencode_decode(b"le") assert result == [] def test_decode_nested_list(self): """Test decoding nested lists.""" - result, remaining = _bencode_decode(b"lli1eeli2eee") + result, _remaining = _bencode_decode(b"lli1eeli2eee") assert result == [[1], [2]] def test_decode_dict(self): @@ -65,14 +67,14 @@ class TestBencodeDecode: def test_decode_empty_dict(self): """Test decoding empty dictionary.""" - result, remaining = _bencode_decode(b"de") + result, _remaining = _bencode_decode(b"de") assert result == {} def test_decode_complex_structure(self): """Test decoding complex nested structures.""" # Dict with string, int, and list values data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee" - result, remaining = _bencode_decode(data) + result, _remaining = _bencode_decode(data) assert result == { b"age": 25, b"name": b"John", diff --git a/tests/prowlarr/test_cache.py b/tests/prowlarr/test_cache.py index 0da3463..91448c1 100644 --- a/tests/prowlarr/test_cache.py +++ b/tests/prowlarr/test_cache.py @@ -3,7 +3,6 @@ Tests for the Prowlarr release cache. """ import time -import pytest # Import the cache module from shelfmark.release_sources.prowlarr import cache @@ -106,7 +105,9 @@ class TestProwlarrCache: def cache_operations(): try: for i in range(100): - cache.cache_release(f"thread-{threading.current_thread().name}-{i}", {"data": i}) + cache.cache_release( + f"thread-{threading.current_thread().name}-{i}", {"data": i} + ) cache.get_release(f"thread-{threading.current_thread().name}-{i}") except Exception as e: errors.append(e) diff --git a/tests/prowlarr/test_clients.py b/tests/prowlarr/test_clients.py index 91c5247..90d22ce 100644 --- a/tests/prowlarr/test_clients.py +++ b/tests/prowlarr/test_clients.py @@ -6,15 +6,14 @@ import pytest import requests from shelfmark.download.clients import ( - DownloadStatus, - DownloadState, - DownloadClient, - register_client, - get_client, - list_configured_clients, - get_all_clients, - with_retry, _CLIENTS, + DownloadClient, + DownloadState, + DownloadStatus, + get_all_clients, + get_client, + register_client, + with_retry, ) diff --git a/tests/prowlarr/test_deluge_client.py b/tests/prowlarr/test_deluge_client.py index 6ec4643..563ad74 100644 --- a/tests/prowlarr/test_deluge_client.py +++ b/tests/prowlarr/test_deluge_client.py @@ -42,7 +42,9 @@ class TestDelugeClientAddDownload: monkeypatch.setattr(client, "_try_set_label", mock_try_set_label) magnet = "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=test" - with patch("shelfmark.download.clients.deluge.extract_torrent_info", autospec=True) as mock_extract: + with patch( + "shelfmark.download.clients.deluge.extract_torrent_info", autospec=True + ) as mock_extract: mock_extract.return_value = TorrentInfo( info_hash="abcdef1234567890abcdef1234567890abcdef12", torrent_data=None, @@ -84,7 +86,9 @@ class TestDelugeClientAddDownload: monkeypatch.setattr(client, "_try_set_label", MagicMock()) magnet = "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=test" - with patch("shelfmark.download.clients.deluge.extract_torrent_info", autospec=True) as mock_extract: + with patch( + "shelfmark.download.clients.deluge.extract_torrent_info", autospec=True + ) as mock_extract: mock_extract.return_value = TorrentInfo( info_hash="abcdef1234567890abcdef1234567890abcdef12", torrent_data=None, @@ -118,7 +122,9 @@ class TestDelugeClientErrors: from shelfmark.download.clients.deluge import DelugeClient client = DelugeClient() - monkeypatch.setattr(client, "_ensure_connected", MagicMock(side_effect=RuntimeError("offline"))) + monkeypatch.setattr( + client, "_ensure_connected", MagicMock(side_effect=RuntimeError("offline")) + ) success, message = client.test_connection() @@ -143,7 +149,9 @@ class TestDelugeClientErrors: client = DelugeClient() monkeypatch.setattr(client, "_ensure_connected", lambda: None) - monkeypatch.setattr(client, "_rpc_call", MagicMock(side_effect=RuntimeError("status failed"))) + monkeypatch.setattr( + client, "_rpc_call", MagicMock(side_effect=RuntimeError("status failed")) + ) status = client.get_status("torrent-id") @@ -167,7 +175,9 @@ class TestDelugeClientErrors: client = DelugeClient() monkeypatch.setattr(client, "_ensure_connected", lambda: None) - monkeypatch.setattr(client, "_rpc_call", MagicMock(side_effect=RuntimeError("remove failed"))) + monkeypatch.setattr( + client, "_rpc_call", MagicMock(side_effect=RuntimeError("remove failed")) + ) assert client.remove("torrent-id") is False @@ -189,10 +199,14 @@ class TestDelugeClientErrors: client._authenticated = True client._connected = True monkeypatch.setattr(client, "_ensure_connected", lambda: None) - monkeypatch.setattr(client, "_rpc_call", MagicMock(side_effect=RuntimeError("lookup failed"))) + monkeypatch.setattr( + client, "_rpc_call", MagicMock(side_effect=RuntimeError("lookup failed")) + ) magnet = "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=test" - with patch("shelfmark.download.clients.deluge.extract_torrent_info", autospec=True) as mock_extract: + with patch( + "shelfmark.download.clients.deluge.extract_torrent_info", autospec=True + ) as mock_extract: mock_extract.return_value = TorrentInfo( info_hash="abcdef1234567890abcdef1234567890abcdef12", torrent_data=None, diff --git a/tests/prowlarr/test_failure_scenarios.py b/tests/prowlarr/test_failure_scenarios.py index b8b3d3d..06f2804 100644 --- a/tests/prowlarr/test_failure_scenarios.py +++ b/tests/prowlarr/test_failure_scenarios.py @@ -7,23 +7,21 @@ They use real clients where possible, with injected failures for edge cases. Run with: uv run pytest tests/prowlarr/test_failure_scenarios.py -v """ +import tempfile import time from pathlib import Path from threading import Event, Thread -from typing import List, Optional, Tuple -from unittest.mock import MagicMock, patch, PropertyMock -import tempfile +from unittest.mock import patch import pytest from shelfmark.core.models import DownloadTask -from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler from shelfmark.download.clients import ( DownloadClient, DownloadState, DownloadStatus, ) - +from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler # ============================================================================= # Test Fixtures and Helpers @@ -34,25 +32,25 @@ class ProgressRecorder: """Records progress and status updates during download.""" def __init__(self): - self.progress_values: List[float] = [] - self.status_updates: List[Tuple[str, Optional[str]]] = [] + self.progress_values: list[float] = [] + self.status_updates: list[tuple[str, str | None]] = [] def progress_callback(self, progress: float): self.progress_values.append(progress) - def status_callback(self, status: str, message: Optional[str]): + def status_callback(self, status: str, message: str | None): self.status_updates.append((status, message)) @property - def last_status(self) -> Optional[str]: + def last_status(self) -> str | None: return self.status_updates[-1][0] if self.status_updates else None @property - def last_message(self) -> Optional[str]: + def last_message(self) -> str | None: return self.status_updates[-1][1] if self.status_updates else None @property - def statuses(self) -> List[str]: + def statuses(self) -> list[str]: return [s[0] for s in self.status_updates] @property @@ -79,15 +77,15 @@ class MockClient(DownloadClient): def is_configured() -> bool: return True - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: return True, "Mock client connected" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: if self.add_download_error: @@ -122,12 +120,12 @@ class MockClient(DownloadClient): self.remove_with_delete = delete_files return True - def get_download_path(self, download_id: str) -> Optional[str]: + def get_download_path(self, download_id: str) -> str | None: return "/downloads/test-file.epub" def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: return None @@ -208,12 +206,15 @@ class TestClientErrorStates: ), ] - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -241,12 +242,15 @@ class TestClientErrorStates: ), ] - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -274,12 +278,15 @@ class TestClientErrorStates: ), ] - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -308,12 +315,15 @@ class TestConnectionFailures: """Handler should report error when add_download throws.""" mock_client.add_download_error = ConnectionError("Connection refused") - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -347,12 +357,15 @@ class TestConnectionFailures: mock_client.get_status = failing_get_status - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -369,15 +382,19 @@ class TestConnectionFailures: self, handler, recorder, cancel_flag, sample_task, sample_release ): """Handler should report helpful error when no client is configured.""" - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=None, - ), patch( - "shelfmark.release_sources.prowlarr.handler.list_configured_clients", - return_value=[], + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=None, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.list_configured_clients", + return_value=[], + ), ): result = handler.download( task=sample_task, @@ -424,15 +441,19 @@ class TestCancellation: cancel_thread = Thread(target=cancel_after_delay) cancel_thread.start() - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.1, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.1, + ), ): result = handler.download( task=sample_task, @@ -454,12 +475,15 @@ class TestCancellation: cancel_flag = Event() cancel_flag.set() # Already cancelled - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -498,9 +522,7 @@ class TestCacheFailures: assert recorder.had_error assert "not found in cache" in recorder.last_message.lower() - def test_release_missing_download_url( - self, handler, recorder, cancel_flag, sample_task - ): + def test_release_missing_download_url(self, handler, recorder, cancel_flag, sample_task): """Handler should error when release has no download URL.""" release_no_url = { "guid": "test-task-123", @@ -572,12 +594,15 @@ class TestFileHandlingFailures: ] mock_client.get_download_path = lambda x: None # No path available - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): result = handler.download( task=sample_task, @@ -617,19 +642,25 @@ class TestFileHandlingFailures: mock_client.get_download_path = lambda x: str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=usenet_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - ) as mock_get_staging, patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=usenet_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + ) as mock_get_staging, + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): result = handler.download( task=sample_task, @@ -674,18 +705,23 @@ class TestProgressCallbacks: mock_client.get_download_path = lambda x: str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): result = handler.download( task=sample_task, @@ -760,18 +796,23 @@ class TestStatusMessages: mock_client.get_download_path = lambda x: str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler.download( task=sample_task, @@ -819,18 +860,23 @@ class TestStatusMessages: mock_client.get_download_path = lambda x: str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler.download( task=sample_task, @@ -875,15 +921,19 @@ class TestErrorCleanup: mock_client.get_status = exploding_get_status - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=sample_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=sample_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): result = handler.download( task=sample_task, @@ -922,12 +972,15 @@ class TestErrorCleanup: usenet_release["protocol"] = "usenet" usenet_release["downloadUrl"] = "https://indexer.example.com/download/123" - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value=usenet_release, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value=usenet_release, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), ): # Should not raise, even though remove() fails result = handler.download( diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index bb0a079..df5eb06 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -5,46 +5,43 @@ These tests mock the download clients to test the handler logic without requiring running services. """ -import os import tempfile from pathlib import Path from threading import Event -from typing import List, Optional, Tuple -from unittest.mock import MagicMock, patch, PropertyMock -import pytest +from unittest.mock import MagicMock, patch from shelfmark.core.models import DownloadTask +from shelfmark.download.clients import ( + DownloadState, + DownloadStatus, +) from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler from shelfmark.release_sources.prowlarr.utils import get_protocol -from shelfmark.download.clients import ( - DownloadStatus, - DownloadState, -) class ProgressRecorder: """Records progress and status updates during download.""" def __init__(self): - self.progress_values: List[float] = [] - self.status_updates: List[Tuple[str, Optional[str]]] = [] + self.progress_values: list[float] = [] + self.status_updates: list[tuple[str, str | None]] = [] def progress_callback(self, progress: float): self.progress_values.append(progress) - def status_callback(self, status: str, message: Optional[str]): + def status_callback(self, status: str, message: str | None): self.status_updates.append((status, message)) @property - def last_status(self) -> Optional[str]: + def last_status(self) -> str | None: return self.status_updates[-1][0] if self.status_updates else None @property - def last_message(self) -> Optional[str]: + def last_message(self) -> str | None: return self.status_updates[-1][1] if self.status_updates else None @property - def statuses(self) -> List[str]: + def statuses(self) -> list[str]: return [s[0] for s in self.status_updates] @@ -197,18 +194,22 @@ class TestProwlarrHandlerDownloadErrors: def test_download_fails_no_client_configured(self): """Test that download fails when no client is configured.""" - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "downloadUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=None, - ), patch( - "shelfmark.release_sources.prowlarr.handler.list_configured_clients", - return_value=[], + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "downloadUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=None, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.list_configured_clients", + return_value=[], + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -287,24 +288,29 @@ class TestProwlarrHandlerSeedCriteria: mock_client.find_existing.return_value = None mock_client.add_download.return_value = "download_id" - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "title": "Test Release", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - "minimumSeedTime": 259200, - "minimumRatio": 1.25, - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch.object( - ProwlarrHandler, - "_poll_and_complete", - return_value=None, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "minimumSeedTime": 259200, + "minimumRatio": 1.25, + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch.object( + ProwlarrHandler, + "_poll_and_complete", + return_value=None, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -336,26 +342,33 @@ class TestProwlarrHandlerExistingDownload: mock_client.name = "qbittorrent" mock_client.find_existing.return_value = None - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/123", - "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", - "title": "Test Release", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch.object( - ProwlarrHandler, - "_poll_and_complete", - return_value=None, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/123", + "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", + "title": "Test Release", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch.object( + ProwlarrHandler, + "_poll_and_complete", + return_value=None, + ), ): handler = ProwlarrHandler() - task = DownloadTask(task_id="torrent-prefers-magnet", source="prowlarr", title="Test Book") + task = DownloadTask( + task_id="torrent-prefers-magnet", source="prowlarr", title="Test Book" + ) cancel_flag = Event() recorder = ProgressRecorder() @@ -376,26 +389,33 @@ class TestProwlarrHandlerExistingDownload: mock_client.name = "sabnzbd" mock_client.find_existing.return_value = None - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "usenet", - "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/456", - "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", - "title": "Test Release", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch.object( - ProwlarrHandler, - "_poll_and_complete", - return_value=None, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "usenet", + "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/456", + "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", + "title": "Test Release", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch.object( + ProwlarrHandler, + "_poll_and_complete", + return_value=None, + ), ): handler = ProwlarrHandler() - task = DownloadTask(task_id="usenet-prefers-download", source="prowlarr", title="Test Book") + task = DownloadTask( + task_id="usenet-prefers-download", source="prowlarr", title="Test Book" + ) cancel_flag = Event() recorder = ProgressRecorder() @@ -435,20 +455,25 @@ class TestProwlarrHandlerExistingDownload: ) mock_client.get_download_path.return_value = str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -476,7 +501,7 @@ class TestProwlarrHandlerPolling: """Tests for download polling behavior.""" def test_retries_torrent_not_found_errors(self): - """"Torrent not found" should be treated as transient.""" + """ "Torrent not found" should be treated as transient.""" with tempfile.TemporaryDirectory() as tmp_dir: source_file = Path(tmp_dir) / "source" / "book.epub" source_file.parent.mkdir(parents=True) @@ -513,23 +538,29 @@ class TestProwlarrHandlerPolling: mock_client.get_status.side_effect = mock_get_status mock_client.get_download_path.return_value = str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -565,18 +596,22 @@ class TestProwlarrHandlerPolling: file_path=None, ) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -637,23 +672,29 @@ class TestProwlarrHandlerPolling: mock_client.get_status.side_effect = mock_get_status mock_client.get_download_path.return_value = str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, # Speed up tests + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, # Speed up tests + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -689,18 +730,22 @@ class TestProwlarrHandlerPolling: file_path=None, ) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -740,18 +785,22 @@ class TestProwlarrHandlerCancellation: file_path=None, ) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -782,9 +831,7 @@ class TestProwlarrHandlerCancel: def test_cancel_removes_from_cache(self): """Test that cancel removes release from cache.""" - with patch( - "shelfmark.release_sources.prowlarr.handler.remove_release" - ) as mock_remove: + with patch("shelfmark.release_sources.prowlarr.handler.remove_release") as mock_remove: handler = ProwlarrHandler() result = handler.cancel("test-task-id") @@ -793,9 +840,7 @@ class TestProwlarrHandlerCancel: def test_cancel_handles_missing_task(self): """Test that cancel handles non-existent task gracefully.""" - with patch( - "shelfmark.release_sources.prowlarr.handler.remove_release" - ): + with patch("shelfmark.release_sources.prowlarr.handler.remove_release"): handler = ProwlarrHandler() result = handler.cancel("nonexistent-task-id") @@ -828,23 +873,29 @@ class TestProwlarrHandlerFileStaging: ) mock_client.get_download_path.return_value = str(source_file) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -891,23 +942,29 @@ class TestProwlarrHandlerFileStaging: ) mock_client.get_download_path.return_value = str(source_dir) - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -957,23 +1014,29 @@ class TestProwlarrHandlerFileStaging: mock_client.get_download_path.return_value = str(source_file) # Use usenet protocol - torrents skip staging and return original path directly - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "usenet", - "downloadUrl": "https://indexer.example.com/download/123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.download.staging.get_staging_dir", - return_value=staging_dir, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "usenet", + "downloadUrl": "https://indexer.example.com/download/123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask( @@ -1086,8 +1149,8 @@ class TestProwlarrHandlerPostProcessCleanup: assert str(args[3]) == "path lookup failed" def test_delete_local_download_data_logs_delete_failure(self, tmp_path, monkeypatch): - import shelfmark.download.clients.base_handler as base_handler import shelfmark.core.path_mappings as path_mappings + import shelfmark.download.clients.base_handler as base_handler handler = ProwlarrHandler() download_file = tmp_path / "downloads" / "book.epub" diff --git a/tests/prowlarr/test_integration_clients.py b/tests/prowlarr/test_integration_clients.py index 3035d92..4490fa7 100644 --- a/tests/prowlarr/test_integration_clients.py +++ b/tests/prowlarr/test_integration_clients.py @@ -1,5 +1,4 @@ -""" -Integration tests for download clients. +"""Integration tests for download clients. These tests require the Docker test stack to be running: docker compose -f docker-compose.test-clients.yml up -d @@ -11,66 +10,146 @@ These tests use the actual Docker stack configuration. Before running: 2. Configure clients via the cwabd UI at http://localhost:8084/settings """ -import subprocess +import threading import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import pytest from shelfmark.core.config import config from shelfmark.core.settings_registry import save_config_file from shelfmark.download.clients import DownloadStatus - # Test magnet link (Ubuntu ISO - legal, small metadata) TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" +_MINIMAL_NZB = b""" + + + + alt.binaries.test + + + integration-message-id + + + +""" + + +def _make_nzb_handler(request_paths: list[str]): + class NZBFixtureHandler(BaseHTTPRequestHandler): + response_body = _MINIMAL_NZB + + def do_GET(self): + request_paths.append(self.path) + if not self.path.endswith(".nzb"): + self.send_response(404) + self.end_headers() + return + + self.send_response(200) + self.send_header("Content-Type", "application/x-nzb") + self.send_header("Content-Length", str(len(self.response_body))) + self.end_headers() + self.wfile.write(self.response_body) + + def log_message(self, format, *args): # noqa: A002 + return + + return NZBFixtureHandler + + +@pytest.fixture +def nzb_fixture_server(): + """Serve a tiny NZB file for live usenet client integration tests.""" + request_paths: list[str] = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), _make_nzb_handler(request_paths)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield { + "base_url": f"http://127.0.0.1:{server.server_address[1]}", + "request_paths": request_paths, + } + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _wait_for_live_status(client, download_id: str, *, attempts: int = 10, delay: float = 0.5): + """Give live usenet clients a short window to register a queued job.""" + last_status = None + for _ in range(attempts): + last_status = client.get_status(download_id) + if last_status.state_value != "error": + return last_status + time.sleep(delay) + return last_status + # ============ Configuration Setup Functions ============ + def _setup_transmission_config(): """Set up Transmission configuration via config files and refresh config.""" - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "transmission", - "TRANSMISSION_URL": "http://transmission:9091", - "TRANSMISSION_USERNAME": "admin", - "TRANSMISSION_PASSWORD": "admin", - "TRANSMISSION_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://transmission:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "admin", + "TRANSMISSION_CATEGORY": "test", + }, + ) config.refresh() def _setup_qbittorrent_config(): """Set up qBittorrent configuration via config files and refresh config.""" - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "qbittorrent", - "QBITTORRENT_URL": "http://qbittorrent:8080", - "QBITTORRENT_USERNAME": "admin", - "QBITTORRENT_PASSWORD": "admin123", - "QBITTORRENT_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "QBITTORRENT_URL": "http://qbittorrent:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "admin123", + "QBITTORRENT_CATEGORY": "test", + }, + ) config.refresh() def _setup_deluge_config(): """Set up Deluge configuration via config files and refresh config.""" - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "deluge", - "DELUGE_HOST": "deluge", - "DELUGE_PORT": "8112", - "DELUGE_PASSWORD": "deluge", - "DELUGE_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "deluge", + "DELUGE_HOST": "deluge", + "DELUGE_PORT": "8112", + "DELUGE_PASSWORD": "deluge", + "DELUGE_CATEGORY": "test", + }, + ) config.refresh() def _setup_nzbget_config(): """Set up NZBGet configuration via config files and refresh config.""" - save_config_file("prowlarr_clients", { - "PROWLARR_USENET_CLIENT": "nzbget", - "NZBGET_URL": "http://nzbget:6789", - "NZBGET_USERNAME": "nzbget", - "NZBGET_PASSWORD": "tegbzn6789", - "NZBGET_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_USENET_CLIENT": "nzbget", + "NZBGET_URL": "http://nzbget:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "tegbzn6789", + "NZBGET_CATEGORY": "test", + }, + ) config.refresh() @@ -79,12 +158,15 @@ def _setup_sabnzbd_config(): api_key = _get_sabnzbd_api_key() if not api_key: return False - save_config_file("prowlarr_clients", { - "PROWLARR_USENET_CLIENT": "sabnzbd", - "SABNZBD_URL": "http://sabnzbd:8080", - "SABNZBD_API_KEY": api_key, - "SABNZBD_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "SABNZBD_URL": "http://sabnzbd:8080", + "SABNZBD_API_KEY": api_key, + "SABNZBD_CATEGORY": "test", + }, + ) config.refresh() return True @@ -92,6 +174,7 @@ def _setup_sabnzbd_config(): def _get_sabnzbd_api_key(): """Extract SABnzbd API key from config file.""" import re + # Try mounted config paths (from docker-compose volumes) config_paths = [ "/sabnzbd-config/sabnzbd.ini", @@ -99,7 +182,7 @@ def _get_sabnzbd_api_key(): ] for config_path in config_paths: try: - with open(config_path, "r") as f: + with open(config_path) as f: content = f.read() match = re.search(r"api_key\s*=\s*(\S+)", content) if match: @@ -111,11 +194,13 @@ def _get_sabnzbd_api_key(): # ============ Client Factory Functions ============ + def _try_get_transmission_client(): """Try to get a working Transmission client, or None if unavailable.""" _setup_transmission_config() try: from shelfmark.download.clients.transmission import TransmissionClient + client = TransmissionClient() client.test_connection() return client @@ -128,6 +213,7 @@ def _try_get_qbittorrent_client(): _setup_qbittorrent_config() try: from shelfmark.download.clients.qbittorrent import QBittorrentClient + client = QBittorrentClient() success, _ = client.test_connection() if success: @@ -142,6 +228,7 @@ def _try_get_deluge_client(): _setup_deluge_config() try: from shelfmark.download.clients.deluge import DelugeClient + client = DelugeClient() success, _ = client.test_connection() if success: @@ -156,6 +243,7 @@ def _try_get_nzbget_client(): _setup_nzbget_config() try: from shelfmark.download.clients.nzbget import NZBGetClient + client = NZBGetClient() success, _ = client.test_connection() if success: @@ -171,6 +259,7 @@ def _try_get_sabnzbd_client(): return None try: from shelfmark.download.clients.sabnzbd import SABnzbdClient + client = SABnzbdClient() success, _ = client.test_connection() if success: @@ -182,12 +271,15 @@ def _try_get_sabnzbd_client(): # ============ Fixtures ============ + @pytest.fixture(scope="module") def transmission_client(): """Get Transmission client if available, skip test otherwise.""" client = _try_get_transmission_client() if client is None: - pytest.skip("Transmission not available - ensure docker-compose.test-clients.yml is running") + pytest.skip( + "Transmission not available - ensure docker-compose.test-clients.yml is running" + ) return client @@ -196,7 +288,9 @@ def qbittorrent_client(): """Get qBittorrent client if available, skip test otherwise.""" client = _try_get_qbittorrent_client() if client is None: - pytest.skip("qBittorrent not available - ensure docker-compose.test-clients.yml is running and check temp password") + pytest.skip( + "qBittorrent not available - ensure docker-compose.test-clients.yml is running and check temp password" + ) return client @@ -223,7 +317,9 @@ def sabnzbd_client(): """Get SABnzbd client if available, skip test otherwise.""" client = _try_get_sabnzbd_client() if client is None: - pytest.skip("SABnzbd not available - ensure docker-compose.test-clients.yml is running and setup wizard completed") + pytest.skip( + "SABnzbd not available - ensure docker-compose.test-clients.yml is running and setup wizard completed" + ) return client @@ -312,7 +408,15 @@ class TestTransmissionIntegration: assert 0 <= status.progress <= 100 # State should be a known value - valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata"} + valid_states = { + "downloading", + "complete", + "error", + "seeding", + "paused", + "queued", + "fetching_metadata", + } assert status.state.value in valid_states # Complete should be boolean @@ -397,7 +501,17 @@ class TestQBittorrentIntegration: assert 0 <= status.progress <= 100 - valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled", "checking"} + valid_states = { + "downloading", + "complete", + "error", + "seeding", + "paused", + "queued", + "fetching_metadata", + "stalled", + "checking", + } state_value = status.state.value if hasattr(status.state, "value") else status.state assert state_value in valid_states @@ -482,7 +596,16 @@ class TestDelugeIntegration: assert 0 <= status.progress <= 100 - valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "checking"} + valid_states = { + "downloading", + "complete", + "error", + "seeding", + "paused", + "queued", + "fetching_metadata", + "checking", + } assert status.state.value in valid_states assert isinstance(status.complete, bool) @@ -505,6 +628,24 @@ class TestNZBGetIntegration: assert success, f"Connection failed: {message}" assert "NZBGet" in message + def test_add_status_and_remove_nzb(self, nzbget_client, nzb_fixture_server): + """Exercise the live NZBGet contract with a real queued NZB.""" + client = nzbget_client + url = f"{nzb_fixture_server['base_url']}/Integration_Book.nzb" + + download_id = client.add_download(url=url, name="Integration_Book") + assert download_id.isdigit() + + status = _wait_for_live_status(client, download_id) + assert status is not None + assert status.complete is False + assert 0 <= status.progress <= 100 + assert status.message + assert status.state_value in {"queued", "downloading", "paused", "processing", "unknown"} + assert nzb_fixture_server["request_paths"] == ["/Integration_Book.nzb"] + + assert client.remove(download_id, delete_files=True) is True + @pytest.mark.integration class TestSABnzbdIntegration: @@ -520,3 +661,34 @@ class TestSABnzbdIntegration: assert success, f"Connection failed: {message}" assert "SABnzbd" in message + + def test_add_find_status_and_remove_nzb(self, sabnzbd_client, nzb_fixture_server): + """Exercise the live SABnzbd contract with queue and lookup behavior.""" + client = sabnzbd_client + url = f"{nzb_fixture_server['base_url']}/Integration_Book.nzb" + + nzo_id = client.add_download(url=url, name="Integration_Book") + assert nzo_id + assert nzo_id.startswith("SABnzbd_nzo_") + + found = None + for _ in range(10): + found = client.find_existing(url) + if found is not None: + break + time.sleep(0.5) + + assert found is not None + found_id, found_status = found + assert found_id == nzo_id + assert isinstance(found_status, DownloadStatus) + + status = _wait_for_live_status(client, nzo_id) + assert status is not None + assert status.complete is False + assert 0 <= status.progress <= 100 + assert status.message + assert status.state_value in {"queued", "downloading", "processing", "paused"} + assert nzb_fixture_server["request_paths"] == ["/Integration_Book.nzb"] + + assert client.remove(nzo_id, delete_files=True, archive=False) is True diff --git a/tests/prowlarr/test_integration_failures.py b/tests/prowlarr/test_integration_failures.py index d6b15cb..52a47c9 100644 --- a/tests/prowlarr/test_integration_failures.py +++ b/tests/prowlarr/test_integration_failures.py @@ -14,12 +14,12 @@ Key scenarios tested: """ import time + import pytest from shelfmark.core.config import config from shelfmark.core.settings_registry import save_config_file -from shelfmark.download.clients import DownloadStatus, DownloadState - +from shelfmark.download.clients import DownloadState, DownloadStatus # Invalid magnet - valid format but non-existent torrent INVALID_MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000&dn=nonexistent" @@ -34,35 +34,44 @@ VALID_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn= def _setup_transmission_config(): - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "transmission", - "TRANSMISSION_URL": "http://transmission:9091", - "TRANSMISSION_USERNAME": "admin", - "TRANSMISSION_PASSWORD": "admin", - "TRANSMISSION_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://transmission:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "admin", + "TRANSMISSION_CATEGORY": "test", + }, + ) config.refresh() def _setup_qbittorrent_config(): - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "qbittorrent", - "QBITTORRENT_URL": "http://qbittorrent:8080", - "QBITTORRENT_USERNAME": "admin", - "QBITTORRENT_PASSWORD": "admin123", - "QBITTORRENT_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "QBITTORRENT_URL": "http://qbittorrent:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "admin123", + "QBITTORRENT_CATEGORY": "test", + }, + ) config.refresh() def _setup_deluge_config(): - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "deluge", - "DELUGE_HOST": "deluge", - "DELUGE_PORT": "8112", - "DELUGE_PASSWORD": "deluge", - "DELUGE_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "deluge", + "DELUGE_HOST": "deluge", + "DELUGE_PORT": "8112", + "DELUGE_PASSWORD": "deluge", + "DELUGE_CATEGORY": "test", + }, + ) config.refresh() @@ -75,6 +84,7 @@ def _try_get_transmission_client(): _setup_transmission_config() try: from shelfmark.download.clients.transmission import TransmissionClient + client = TransmissionClient() client.test_connection() return client @@ -86,6 +96,7 @@ def _try_get_qbittorrent_client(): _setup_qbittorrent_config() try: from shelfmark.download.clients.qbittorrent import QBittorrentClient + client = QBittorrentClient() success, _ = client.test_connection() if success: @@ -99,6 +110,7 @@ def _try_get_deluge_client(): _setup_deluge_config() try: from shelfmark.download.clients.deluge import DelugeClient + client = DelugeClient() success, _ = client.test_connection() if success: @@ -349,12 +361,11 @@ class TestConnectionResilience: transmission_client.test_connection() # Manually invalidate the session ID if accessible - if hasattr(transmission_client, '_session_id'): - old_session = transmission_client._session_id + if hasattr(transmission_client, "_session_id"): transmission_client._session_id = "invalid-session-id" # Should auto-recover with a new session - success, msg = transmission_client.test_connection() + success, _msg = transmission_client.test_connection() # Restore or verify it got a new one assert success or transmission_client._session_id != "invalid-session-id" @@ -365,11 +376,11 @@ class TestConnectionResilience: qbittorrent_client.test_connection() # Clear the session if accessible - if hasattr(qbittorrent_client, '_session'): + if hasattr(qbittorrent_client, "_session"): qbittorrent_client._session.cookies.clear() # Should re-authenticate automatically - success, msg = qbittorrent_client.test_connection() + success, _msg = qbittorrent_client.test_connection() assert success diff --git a/tests/prowlarr/test_integration_handler.py b/tests/prowlarr/test_integration_handler.py index 6ffe34d..2de75b7 100644 --- a/tests/prowlarr/test_integration_handler.py +++ b/tests/prowlarr/test_integration_handler.py @@ -8,16 +8,15 @@ Run with: docker compose -f docker-compose.test-clients.yml exec shelfmark uv ru import time from threading import Event -from typing import List, Optional, Tuple + import pytest from shelfmark.core.config import config -from shelfmark.core.settings_registry import save_config_file from shelfmark.core.models import DownloadTask +from shelfmark.core.settings_registry import save_config_file +from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler from shelfmark.release_sources.prowlarr.utils import get_protocol -from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release, _cache - # Test magnet link TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" @@ -25,13 +24,16 @@ TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=u def _setup_transmission_config(): """Set up Transmission configuration via config files and refresh config.""" - save_config_file("prowlarr_clients", { - "PROWLARR_TORRENT_CLIENT": "transmission", - "TRANSMISSION_URL": "http://transmission:9091", - "TRANSMISSION_USERNAME": "admin", - "TRANSMISSION_PASSWORD": "admin", - "TRANSMISSION_CATEGORY": "test", - }) + save_config_file( + "prowlarr_clients", + { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://transmission:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "admin", + "TRANSMISSION_CATEGORY": "test", + }, + ) config.refresh() @@ -40,6 +42,7 @@ def _is_transmission_available(): _setup_transmission_config() try: from shelfmark.download.clients.transmission import TransmissionClient + client = TransmissionClient() success, _ = client.test_connection() return success @@ -51,25 +54,25 @@ class ProgressRecorder: """Records progress and status updates during download.""" def __init__(self): - self.progress_values: List[float] = [] - self.status_updates: List[Tuple[str, Optional[str]]] = [] + self.progress_values: list[float] = [] + self.status_updates: list[tuple[str, str | None]] = [] def progress_callback(self, progress: float): self.progress_values.append(progress) - def status_callback(self, status: str, message: Optional[str]): + def status_callback(self, status: str, message: str | None): self.status_updates.append((status, message)) @property - def last_status(self) -> Optional[str]: + def last_status(self) -> str | None: return self.status_updates[-1][0] if self.status_updates else None @property - def last_message(self) -> Optional[str]: + def last_message(self) -> str | None: return self.status_updates[-1][1] if self.status_updates else None @property - def statuses(self) -> List[str]: + def statuses(self) -> list[str]: return [s[0] for s in self.status_updates] @@ -131,11 +134,14 @@ class TestHandlerCacheOperations: handler = ProwlarrHandler() task_id = "no-url-release-test" - cache_release(task_id, { - "protocol": "torrent", - "title": "Test Release", - # No downloadUrl or magnetUrl - }) + cache_release( + task_id, + { + "protocol": "torrent", + "title": "Test Release", + # No downloadUrl or magnetUrl + }, + ) try: task = DownloadTask( @@ -186,7 +192,9 @@ class TestHandlerCacheOperations: def transmission_available(): """Check if Transmission is available, skip if not.""" if not _is_transmission_available(): - pytest.skip("Transmission not available - ensure docker-compose.test-clients.yml is running") + pytest.skip( + "Transmission not available - ensure docker-compose.test-clients.yml is running" + ) return True @@ -201,11 +209,14 @@ class TestProwlarrHandlerWithTransmission: # Cache a valid release task_id = f"test-cancel-release-{time.time()}" - cache_release(task_id, { - "protocol": "torrent", - "title": "Ubuntu Test ISO", - "magnetUrl": TEST_MAGNET, - }) + cache_release( + task_id, + { + "protocol": "torrent", + "title": "Ubuntu Test ISO", + "magnetUrl": TEST_MAGNET, + }, + ) task = DownloadTask( task_id=task_id, @@ -239,7 +250,11 @@ class TestProwlarrHandlerWithTransmission: # Should have some status updates assert len(recorder.status_updates) > 0 # Should see resolving or downloading status (not just error) - assert "resolving" in recorder.statuses or "downloading" in recorder.statuses or "cancelled" in recorder.statuses + assert ( + "resolving" in recorder.statuses + or "downloading" in recorder.statuses + or "cancelled" in recorder.statuses + ) def test_handler_sends_to_transmission(self, transmission_available): """Test that handler properly sends downloads to Transmission.""" @@ -247,11 +262,14 @@ class TestProwlarrHandlerWithTransmission: handler = ProwlarrHandler() task_id = f"transmission-test-{time.time()}" - cache_release(task_id, { - "protocol": "torrent", - "title": "Integration Test Torrent", - "magnetUrl": TEST_MAGNET, - }) + cache_release( + task_id, + { + "protocol": "torrent", + "title": "Integration Test Torrent", + "magnetUrl": TEST_MAGNET, + }, + ) task = DownloadTask( task_id=task_id, diff --git a/tests/prowlarr/test_nzbget_client.py b/tests/prowlarr/test_nzbget_client.py index 2bb9b6d..89343fa 100644 --- a/tests/prowlarr/test_nzbget_client.py +++ b/tests/prowlarr/test_nzbget_client.py @@ -1,14 +1,14 @@ -""" -Unit tests for the NZBGet client. +"""Unit tests for the NZBGet client. These tests mock the requests library to test the client logic without requiring a running NZBGet instance. """ +import base64 +import json from unittest.mock import MagicMock, patch -import pytest -from shelfmark.download.clients import DownloadStatus +import pytest class TestNZBGetClientIsConfigured: @@ -321,6 +321,94 @@ class TestNZBGetClientGetStatus: assert status.complete is True assert status.file_path == "/downloads/completed/book" + def test_get_status_complete_prefers_final_dir_and_normalizes_path(self, monkeypatch): + """Test completed jobs use FinalDir and normalize redundant separators.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "shelfmark.download.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [] + if method == "history": + return [ + { + "NZBID": 123, + "Status": "SUCCESS", + "FinalDir": "/downloads/completed//book/./", + "DestDir": "/downloads/completed/book", + } + ] + return [] + + from shelfmark.download.clients.nzbget import NZBGetClient + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.complete is True + assert status.file_path == "/downloads/completed/book" + + def test_get_status_processing_in_queue(self, monkeypatch): + """Test processing status mapping for queued NZB jobs.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "shelfmark.download.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [ + { + "NZBID": 123, + "FileSizeHi": 0, + "FileSizeLo": 100000000, + "RemainingSizeHi": 0, + "RemainingSizeLo": 1000000, + "Status": "POST-PROCESSING", + "DownloadRate": 0, + "RemainingSec": 0, + } + ] + return [] + + from shelfmark.download.clients.nzbget import NZBGetClient + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.state_value == "processing" + assert status.complete is False + assert status.file_path is None + assert status.message == "Post Processing" + def test_get_status_failed_in_history(self, monkeypatch): """Test status for failed NZB in history.""" config_values = { @@ -467,12 +555,15 @@ class TestNZBGetClientAddDownload: mock_post_response = MagicMock() mock_post_response.json.return_value = {"result": 456} - with patch( - "shelfmark.download.clients.nzbget.requests.get", - return_value=mock_get_response, - ), patch( - "shelfmark.download.clients.nzbget.requests.post", - return_value=mock_post_response, + with ( + patch( + "shelfmark.download.clients.nzbget.requests.get", + return_value=mock_get_response, + ), + patch( + "shelfmark.download.clients.nzbget.requests.post", + return_value=mock_post_response, + ), ): from shelfmark.download.clients.nzbget import ( NZBGetClient, @@ -486,6 +577,48 @@ class TestNZBGetClientAddDownload: assert result == "456" + def test_add_download_uses_configured_category_and_nzb_filename(self, monkeypatch): + """Test add_download sends the expected NZBGet append payload.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "shelfmark.download.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_get_response = MagicMock() + mock_get_response.content = b"test" + + mock_post_response = MagicMock() + mock_post_response.json.return_value = {"result": "789"} + + with ( + patch( + "shelfmark.download.clients.nzbget.requests.get", + return_value=mock_get_response, + ), + patch( + "shelfmark.download.clients.nzbget.requests.post", + return_value=mock_post_response, + ) as mock_post, + ): + from shelfmark.download.clients.nzbget import NZBGetClient + + client = NZBGetClient() + result = client.add_download("https://example.com/download", "Test Book") + + assert result == "789" + + payload = json.loads(mock_post.call_args.kwargs["data"]) + assert payload["method"] == "append" + assert payload["params"][0] == "Test Book.nzb" + assert payload["params"][1] == base64.b64encode(b"test").decode("ascii") + assert payload["params"][2] == "Books" + def test_add_download_fetch_failure(self, monkeypatch): """Test handling of NZB fetch failure.""" import requests @@ -629,4 +762,8 @@ class TestNZBGetClientRemove: result = client.remove("123", delete_files=True) assert result is True - assert [call[1][0] for call in calls] == ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"] + assert [call[1][0] for call in calls] == [ + "GroupFinalDelete", + "HistoryFinalDelete", + "HistoryDelete", + ] diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index b279949..202a602 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -5,8 +5,8 @@ These tests mock the qbittorrentapi library to test the client logic without requiring a running qBittorrent instance. """ -import sys from unittest.mock import MagicMock, patch + import pytest from shelfmark.download.clients import DownloadStatus @@ -51,7 +51,9 @@ def create_mock_session_response(torrents, status_code=200): """Create a mock response for _session.get() calls.""" mock_response = MagicMock() mock_response.status_code = status_code - mock_response.json.return_value = [t.to_dict() if isinstance(t, MockTorrent) else t for t in torrents] + mock_response.json.return_value = [ + t.to_dict() if isinstance(t, MockTorrent) else t for t in torrents + ] mock_response.raise_for_status = MagicMock() return mock_response @@ -132,10 +134,12 @@ class TestQBittorrentClientTestConnection: mock_client_class = MagicMock(return_value=mock_client_instance) # Mock the import inside the module - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): # Need to reimport after patching import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -161,9 +165,11 @@ class TestQBittorrentClientTestConnection: mock_client_instance.auth_log_in.side_effect = RuntimeError("401 Unauthorized") mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -192,12 +198,16 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.5, state="downloading", dlspeed=1024000, eta=3600) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -229,12 +239,16 @@ class TestQBittorrentClientGetStatus: ) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -266,19 +280,26 @@ class TestQBittorrentClientGetStatus: mock_client_instance = MagicMock() info_payload = mock_torrent.to_dict() | {"save_path": "/downloads/shelfmark"} - mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [info_payload], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() status = client.get_status("abc123") assert status.complete is True - assert status.file_path == "/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub" + assert ( + status.file_path + == "/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub" + ) def test_get_status_paused_up_complete(self, monkeypatch): """qBittorrent-compatible clients may report completed items as pausedUP.""" @@ -301,12 +322,16 @@ class TestQBittorrentClientGetStatus: ) mock_client_instance = MagicMock() info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"} - mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [info_payload], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -368,9 +393,11 @@ class TestQBittorrentClientGetStatus: mock_client_instance._session.get.side_effect = get_side_effect mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -378,6 +405,7 @@ class TestQBittorrentClientGetStatus: assert status.complete is True assert status.file_path == "/downloads/Some Torrent" + def test_get_status_not_found(self, monkeypatch): """Test status for non-existent torrent.""" config_values = { @@ -400,9 +428,11 @@ class TestQBittorrentClientGetStatus: ] mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -428,12 +458,16 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.3, state="stalledDL") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -459,12 +493,16 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.5, state="pausedDL") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -488,12 +526,16 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.1, state="error") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -523,12 +565,16 @@ class TestQBittorrentClientAddDownload: mock_client_instance.torrents_add.return_value = "Ok." mock_client_instance.torrents_info.return_value = [mock_torrent] # Used by the properties check - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -556,12 +602,16 @@ class TestQBittorrentClientAddDownload: mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "Ok." mock_client_instance.torrents_info.return_value = [mock_torrent] - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) with patch( @@ -608,12 +658,16 @@ class TestQBittorrentClientAddDownload: mock_client_instance.torrents_add.return_value = "Ok." mock_client_instance.torrents_info.return_value = [mock_torrent] # Used by the properties check - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -638,12 +692,16 @@ class TestQBittorrentClientAddDownload: valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "" - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -671,9 +729,11 @@ class TestQBittorrentClientAddDownload: mock_client_instance.torrents_add.return_value = "Fails." mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -698,12 +758,16 @@ class TestQBittorrentClientAddDownload: valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "Ok." - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -731,12 +795,16 @@ class TestQBittorrentClientAddDownload: valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "Ok." - mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + {}, status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) with patch( @@ -776,9 +844,11 @@ class TestQBittorrentClientRemove: mock_client_instance = MagicMock() mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -806,9 +876,11 @@ class TestQBittorrentClientRemove: mock_client_instance.torrents_delete.side_effect = RuntimeError("Not found") mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -837,12 +909,16 @@ class TestQBittorrentClientGetDownloadPath: content_path="/downloads/some/book.epub", ) mock_client_instance = MagicMock() - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -872,12 +948,16 @@ class TestQBittorrentClientGetDownloadPath: mock_client_instance = MagicMock() info_payload = mock_torrent.to_dict() | {"save_path": "/downloads/shelfmark"} - mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [info_payload], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -933,9 +1013,11 @@ class TestQBittorrentClientGetDownloadPath: mock_client_instance._session.get.side_effect = get_side_effect mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -992,9 +1074,11 @@ class TestQBittorrentClientGetDownloadPath: mock_client_instance._session.get.side_effect = get_side_effect mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -1026,12 +1110,16 @@ class TestQBittorrentClientFindExisting: ) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_instance._session.get.return_value = create_mock_session_response( + [mock_torrent], status_code=200 + ) mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -1065,9 +1153,11 @@ class TestQBittorrentClientFindExisting: ] mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -1092,9 +1182,11 @@ class TestQBittorrentClientFindExisting: mock_client_instance = MagicMock() mock_client_class = MagicMock(return_value=mock_client_instance) - with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) client = qb_module.QBittorrentClient() @@ -1108,15 +1200,18 @@ class TestHashesMatch: def test_identical_hashes_match(self): from shelfmark.download.clients.qbittorrent import _hashes_match + assert _hashes_match("abc123", "abc123") is True assert _hashes_match("ABC123", "abc123") is True def test_different_hashes_dont_match(self): from shelfmark.download.clients.qbittorrent import _hashes_match + assert _hashes_match("abc123", "def456") is False def test_amarr_padded_hash_matches_ed2k_hash(self): from shelfmark.download.clients.qbittorrent import _hashes_match + ed2k_hash = "0320c47b3baa01f8d5f42cd7c05ce28d" # 32 chars padded_hash = "0320c47b3baa01f8d5f42cd7c05ce28d00000000" # 40 chars assert _hashes_match(padded_hash, ed2k_hash) is True @@ -1124,17 +1219,20 @@ class TestHashesMatch: def test_non_zero_padded_40_char_hash_doesnt_match(self): from shelfmark.download.clients.qbittorrent import _hashes_match + bittorrent_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" partial_hash = "3b245504cf5f11bbdbe1201cea6a6bf4" assert _hashes_match(bittorrent_hash, partial_hash) is False def test_matching_is_case_insensitive(self): from shelfmark.download.clients.qbittorrent import _hashes_match + ed2k_hash = "0320C47B3BAA01F8D5F42CD7C05CE28D" padded_hash = "0320c47b3baa01f8d5f42cd7c05ce28d00000000" assert _hashes_match(padded_hash, ed2k_hash) is True def test_wrong_length_hashes_dont_match(self): from shelfmark.download.clients.qbittorrent import _hashes_match + assert _hashes_match("a" * 40, "b" * 30) is False assert _hashes_match("a" * 38, "b" * 32) is False diff --git a/tests/prowlarr/test_remote_path_mappings.py b/tests/prowlarr/test_remote_path_mappings.py index 7303d2e..2706410 100644 --- a/tests/prowlarr/test_remote_path_mappings.py +++ b/tests/prowlarr/test_remote_path_mappings.py @@ -57,23 +57,29 @@ def test_remaps_completed_path_when_remote_path_missing(): ] return default - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.release_sources.prowlarr.handler.config.get", - side_effect=config_get, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask(task_id="poll-mapping-test", source="prowlarr", title="Test Book") @@ -129,23 +135,29 @@ def test_remap_prefers_mapping_when_original_exists(): ] return default - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.release_sources.prowlarr.handler.config.get", - side_effect=config_get, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask(task_id="poll-mapping-prefer", source="prowlarr", title="Test Book") @@ -199,26 +211,34 @@ def test_remap_fails_when_mapping_exists_but_path_missing(): ] return default - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.release_sources.prowlarr.handler.config.get", - side_effect=config_get, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() - task = DownloadTask(task_id="poll-mapping-missing", source="prowlarr", title="Test Book") + task = DownloadTask( + task_id="poll-mapping-missing", source="prowlarr", title="Test Book" + ) cancel_flag = Event() recorder = ProgressRecorder() @@ -270,23 +290,29 @@ def test_remaps_windows_path_to_linux(): ] return default - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.release_sources.prowlarr.handler.config.get", - side_effect=config_get, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() task = DownloadTask(task_id="windows-path-test", source="prowlarr", title="Test Book") @@ -344,26 +370,34 @@ def test_windows_path_case_insensitive_matching(): ] return default - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - }, - ), patch( - "shelfmark.release_sources.prowlarr.handler.get_client", - return_value=mock_client, - ), patch( - "shelfmark.release_sources.prowlarr.handler.remove_release", - ), patch( - "shelfmark.release_sources.prowlarr.handler.config.get", - side_effect=config_get, - ), patch( - "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", - 0.01, + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), + patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), + patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ), ): handler = ProwlarrHandler() - task = DownloadTask(task_id="case-insensitive-test", source="prowlarr", title="Test Book") + task = DownloadTask( + task_id="case-insensitive-test", source="prowlarr", title="Test Book" + ) cancel_flag = Event() recorder = ProgressRecorder() diff --git a/tests/prowlarr/test_rtorrent_client.py b/tests/prowlarr/test_rtorrent_client.py index b9391bf..b4160ff 100644 --- a/tests/prowlarr/test_rtorrent_client.py +++ b/tests/prowlarr/test_rtorrent_client.py @@ -5,11 +5,10 @@ These tests mock the xmlrpc library to test the client logic without requiring a running rTorrent instance. """ -from unittest.mock import MagicMock, patch -import pytest import sys +from unittest.mock import MagicMock, patch -from shelfmark.download.clients import DownloadStatus +import pytest def make_config_getter(values): @@ -247,9 +246,7 @@ class TestRTorrentClientAddDownload: return_value=mock_torrent_info, ): if "shelfmark.download.clients.rtorrent" in sys.modules: - del sys.modules[ - "shelfmark.download.clients.rtorrent" - ] + del sys.modules["shelfmark.download.clients.rtorrent"] from shelfmark.download.clients.rtorrent import ( RTorrentClient, @@ -298,18 +295,14 @@ class TestRTorrentClientAddDownload: return_value=mock_torrent_info, ): if "shelfmark.download.clients.rtorrent" in sys.modules: - del sys.modules[ - "shelfmark.download.clients.rtorrent" - ] + del sys.modules["shelfmark.download.clients.rtorrent"] from shelfmark.download.clients.rtorrent import ( RTorrentClient, ) client = RTorrentClient() - result_hash = client.add_download( - "http://example.com/test.torrent", "Test Torrent" - ) + result_hash = client.add_download("http://example.com/test.torrent", "Test Torrent") assert result_hash == "abc123def456" mock_rpc.load.raw_start.assert_called_once() @@ -343,9 +336,7 @@ class TestRTorrentClientAddDownload: return_value=mock_torrent_info, ): if "shelfmark.download.clients.rtorrent" in sys.modules: - del sys.modules[ - "shelfmark.download.clients.rtorrent" - ] + del sys.modules["shelfmark.download.clients.rtorrent"] from shelfmark.download.clients.rtorrent import ( RTorrentClient, diff --git a/tests/prowlarr/test_sabnzbd_client.py b/tests/prowlarr/test_sabnzbd_client.py index af50788..ec4dd40 100644 --- a/tests/prowlarr/test_sabnzbd_client.py +++ b/tests/prowlarr/test_sabnzbd_client.py @@ -6,9 +6,8 @@ without requiring a running SABnzbd instance. """ from unittest.mock import MagicMock, patch -import pytest -from shelfmark.download.clients import DownloadStatus +import pytest class TestSABnzbdClientIsConfigured: @@ -487,19 +486,64 @@ class TestSABnzbdClientAddDownload: SABnzbdClient, ) - with patch.object(SABnzbdClient, "_fetch_nzb_content", return_value=b"nzbdata"): - with patch.object( + with ( + patch.object( + SABnzbdClient, + "_fetch_nzb_content", + return_value=b"nzbdata", + ), + patch.object( SABnzbdClient, "_api_post_file", return_value={"status": True, "nzo_ids": ["SABnzbd_nzo_xyz789"]}, - ): - client = SABnzbdClient() - result = client.add_download( - "https://example.com/download.nzb", - "Test Book", - ) + ), + ): + client = SABnzbdClient() + result = client.add_download( + "https://example.com/download.nzb", + "Test Book", + ) - assert result == "SABnzbd_nzo_xyz789" + assert result == "SABnzbd_nzo_xyz789" + + def test_add_download_uses_configured_category_and_nzb_filename(self, monkeypatch): + """Test add_download posts the expected SABnzbd payload.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "books", + } + monkeypatch.setattr( + "shelfmark.download.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_get_response = MagicMock() + mock_get_response.content = b"test" + + mock_post_response = MagicMock() + mock_post_response.json.return_value = {"status": True, "nzo_ids": ["SABnzbd_nzo_xyz789"]} + + with ( + patch( + "shelfmark.download.clients.sabnzbd.requests.get", + return_value=mock_get_response, + ), + patch( + "shelfmark.download.clients.sabnzbd.requests.post", + return_value=mock_post_response, + ) as mock_post, + ): + from shelfmark.download.clients.sabnzbd import SABnzbdClient + + client = SABnzbdClient() + result = client.add_download("https://example.com/download.nzb.gz", "Test Book") + + assert result == "SABnzbd_nzo_xyz789" + assert mock_post.call_args.kwargs["params"]["mode"] == "addfile" + assert mock_post.call_args.kwargs["params"]["cat"] == "books" + assert mock_post.call_args.kwargs["params"]["nzbname"] == "Test Book" + assert mock_post.call_args.kwargs["files"]["name"][0] == "Test Book.nzb.gz" def test_add_download_no_nzo_id(self, monkeypatch): """Test add_download when SABnzbd returns no nzo_id.""" @@ -517,22 +561,28 @@ class TestSABnzbdClientAddDownload: SABnzbdClient, ) - with patch.object(SABnzbdClient, "_fetch_nzb_content", return_value=b"nzbdata"): - with patch.object( + with ( + patch.object( + SABnzbdClient, + "_fetch_nzb_content", + return_value=b"nzbdata", + ), + patch.object( SABnzbdClient, "_api_post_file", return_value={"status": True, "nzo_ids": []}, - ): - with patch.object( - SABnzbdClient, - "_api_call", - return_value={"status": True, "nzo_ids": []}, - ): - client = SABnzbdClient() - with pytest.raises(Exception) as exc_info: - client.add_download("https://example.com/download.nzb", "Test") + ), + patch.object( + SABnzbdClient, + "_api_call", + return_value={"status": True, "nzo_ids": []}, + ), + ): + client = SABnzbdClient() + with pytest.raises(Exception) as exc_info: + client.add_download("https://example.com/download.nzb", "Test") - assert "nzo_id" in str(exc_info.value).lower() + assert "nzo_id" in str(exc_info.value).lower() def test_add_download_fallback_to_addurl(self, monkeypatch): """Test fallback to addurl when NZB fetch fails.""" @@ -552,21 +602,23 @@ class TestSABnzbdClientAddDownload: SABnzbdClient, ) - with patch.object( - SABnzbdClient, - "_fetch_nzb_content", - side_effect=requests.RequestException("Fetch failed"), - ): - with patch.object( + with ( + patch.object( + SABnzbdClient, + "_fetch_nzb_content", + side_effect=requests.RequestException("Fetch failed"), + ), + patch.object( SABnzbdClient, "_api_call", return_value={"status": True, "nzo_ids": ["SABnzbd_nzo_fallback"]}, - ) as mock_api_call: - client = SABnzbdClient() - result = client.add_download("https://example.com/download.nzb", "Test Book") + ) as mock_api_call, + ): + client = SABnzbdClient() + result = client.add_download("https://example.com/download.nzb", "Test Book") - assert result == "SABnzbd_nzo_fallback" - assert mock_api_call.call_args[0][0] == "addurl" + assert result == "SABnzbd_nzo_fallback" + assert mock_api_call.call_args[0][0] == "addurl" class TestSABnzbdClientRemove: @@ -643,6 +695,44 @@ class TestSABnzbdClientRemove: assert result is True assert call_count["history"] == 1 + def test_remove_from_history_passes_archive_flag(self, monkeypatch): + """Test remove forwards the archive flag to history deletes.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "books", + } + monkeypatch.setattr( + "shelfmark.download.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + history_calls = [] + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"status": False} + if mode == "history": + history_calls.append(params or {}) + return {"status": True} + return {} + + from shelfmark.download.clients.sabnzbd import SABnzbdClient + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.remove("SABnzbd_nzo_abc123", delete_files=True, archive=False) + + assert result is True + assert history_calls == [ + {"name": "delete", "value": "SABnzbd_nzo_abc123", "del_files": 1, "archive": 0} + ] + class TestSABnzbdClientFindExisting: """Tests for SABnzbdClient.find_existing().""" @@ -692,7 +782,7 @@ class TestSABnzbdClientFindExisting: result = client.find_existing("https://example.com/Test_Book.nzb") assert result is not None - nzo_id, status = result + nzo_id, _status = result assert nzo_id == "SABnzbd_nzo_found" def test_find_existing_in_history(self, monkeypatch): @@ -740,7 +830,7 @@ class TestSABnzbdClientFindExisting: result = client.find_existing("https://example.com/Test%20Book.nzb") assert result is not None - nzo_id, status = result + nzo_id, _status = result assert nzo_id == "SABnzbd_nzo_history" def test_find_existing_not_found(self, monkeypatch): diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 7e95a7b..0309e19 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -4,17 +4,15 @@ Tests for the Prowlarr source module. Tests the utility functions for parsing release metadata. """ -import pytest - # Import the functions to test +from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources.prowlarr.source import ( ProwlarrSource, - _parse_size, - _extract_format, _detect_content_type_from_categories, + _extract_format, + _parse_size, ) from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url -from shelfmark.metadata_providers import BookMetadata class TestParseSize: diff --git a/tests/prowlarr/test_torrent_utils.py b/tests/prowlarr/test_torrent_utils.py index 2d1d513..2d330fb 100644 --- a/tests/prowlarr/test_torrent_utils.py +++ b/tests/prowlarr/test_torrent_utils.py @@ -14,11 +14,11 @@ import hashlib import pytest from shelfmark.download.clients.torrent_utils import ( - parse_transmission_url, bencode_decode, bencode_encode, - extract_info_hash_from_torrent, extract_hash_from_magnet, + extract_info_hash_from_torrent, + parse_transmission_url, ) @@ -43,7 +43,9 @@ class TestParseTransmissionUrl: def test_parse_url_with_path(self): """Test parsing URL with existing path.""" - protocol, host, port, path = parse_transmission_url("http://localhost:9091/transmission/rpc") + protocol, host, port, path = parse_transmission_url( + "http://localhost:9091/transmission/rpc" + ) assert protocol == "http" assert host == "localhost" assert port == 9091 @@ -75,7 +77,9 @@ class TestParseTransmissionUrl: def test_parse_https_url(self): """Test parsing HTTPS URL.""" - protocol, host, port, path = parse_transmission_url("https://secure.transmission.local:9091") + protocol, host, port, path = parse_transmission_url( + "https://secure.transmission.local:9091" + ) assert protocol == "https" assert host == "secure.transmission.local" assert port == 9091 @@ -109,17 +113,17 @@ class TestBencodeDecode: def test_decode_negative_integer(self): """Test decoding negative integers.""" - result, remaining = bencode_decode(b"i-42e") + result, _remaining = bencode_decode(b"i-42e") assert result == -42 def test_decode_zero(self): """Test decoding zero.""" - result, remaining = bencode_decode(b"i0e") + result, _remaining = bencode_decode(b"i0e") assert result == 0 def test_decode_large_integer(self): """Test decoding large integers.""" - result, remaining = bencode_decode(b"i999999999999e") + result, _remaining = bencode_decode(b"i999999999999e") assert result == 999999999999 def test_decode_string(self): @@ -130,14 +134,14 @@ class TestBencodeDecode: def test_decode_empty_string(self): """Test decoding empty string.""" - result, remaining = bencode_decode(b"0:") + result, _remaining = bencode_decode(b"0:") assert result == b"" def test_decode_unicode_string(self): """Test decoding unicode bytes.""" - data = "tëst".encode("utf-8") + data = "tëst".encode() encoded = f"{len(data)}:".encode() + data - result, remaining = bencode_decode(encoded) + result, _remaining = bencode_decode(encoded) assert result == data def test_decode_list(self): @@ -148,17 +152,17 @@ class TestBencodeDecode: def test_decode_empty_list(self): """Test decoding empty list.""" - result, remaining = bencode_decode(b"le") + result, _remaining = bencode_decode(b"le") assert result == [] def test_decode_nested_list(self): """Test decoding nested lists.""" - result, remaining = bencode_decode(b"lli1eeli2eee") + result, _remaining = bencode_decode(b"lli1eeli2eee") assert result == [[1], [2]] def test_decode_mixed_list(self): """Test decoding list with mixed types.""" - result, remaining = bencode_decode(b"l5:helloi42ee") + result, _remaining = bencode_decode(b"l5:helloi42ee") assert result == [b"hello", 42] def test_decode_dict(self): @@ -169,14 +173,14 @@ class TestBencodeDecode: def test_decode_empty_dict(self): """Test decoding empty dictionary.""" - result, remaining = bencode_decode(b"de") + result, _remaining = bencode_decode(b"de") assert result == {} def test_decode_complex_structure(self): """Test decoding complex nested structures.""" # Dict with string, int, and list values data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee" - result, remaining = bencode_decode(data) + result, _remaining = bencode_decode(data) assert result == { b"age": 25, b"name": b"John", @@ -230,8 +234,6 @@ class TestBencodeEncode: result = bencode_encode(data) assert result == b"d4:listli1ei2ei3ee3:numi42ee" - - def test_encode_invalid_type_raises(self): """Test that invalid types raise ValueError.""" with pytest.raises(ValueError): diff --git a/tests/prowlarr/test_transmission_client.py b/tests/prowlarr/test_transmission_client.py index 2790c85..d9ae897 100644 --- a/tests/prowlarr/test_transmission_client.py +++ b/tests/prowlarr/test_transmission_client.py @@ -54,8 +54,10 @@ class MockSession: def make_config_getter(values): """Create a config.get function that returns values from a dict.""" + def getter(key, default=""): return values.get(key, default) + return getter @@ -626,9 +628,7 @@ class TestTransmissionClientRemove: result = client.remove("abc123", delete_files=True) assert result is True - mock_client_instance.remove_torrent.assert_called_once_with( - "abc123", delete_data=True - ) + mock_client_instance.remove_torrent.assert_called_once_with("abc123", delete_data=True) def test_remove_failure(self, monkeypatch): """Test failed torrent removal."""