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 = """